<?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: Omer Hochman</title>
    <description>The latest articles on DEV Community by Omer Hochman (@omer_hochman).</description>
    <link>https://dev.to/omer_hochman</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%2F4020184%2F5259d759-5650-4fbd-b73e-93bf595a8914.jpg</url>
      <title>DEV Community: Omer Hochman</title>
      <link>https://dev.to/omer_hochman</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/omer_hochman"/>
    <language>en</language>
    <item>
      <title>The duplicate-rows query you re-Google every six weeks</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Tue, 18 Aug 2026 01:22:27 +0000</pubDate>
      <link>https://dev.to/omer_hochman/the-duplicate-rows-query-you-re-google-every-six-weeks-39km</link>
      <guid>https://dev.to/omer_hochman/the-duplicate-rows-query-you-re-google-every-six-weeks-39km</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/find-duplicate-rows-you-re-google-every-time/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;There is a query nobody memorises and everybody needs: find the rows that are duplicated. A customer signed up twice, an import ran twice, a join fanned out and doubled every row. The answer has been the same for thirty years — &lt;code&gt;GROUP BY&lt;/code&gt; the suspect columns, &lt;code&gt;HAVING COUNT(*) &amp;gt; 1&lt;/code&gt; — and yet if you are not writing SQL daily you look it up &lt;em&gt;every single time&lt;/em&gt;, because the shape is just unusual enough to not stick.&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="c1"&gt;-- Which emails appear more than once?&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;email&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;AS&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;customers&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;email&lt;/span&gt;
&lt;span class="k"&gt;HAVING&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="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  The trap is not difficulty — it is that the shape changes under you
&lt;/h2&gt;

&lt;p&gt;It bites in quiet ways. Group by the wrong columns and you under- or over-count — the grain of the query &lt;em&gt;is&lt;/em&gt; the definition of "duplicate," and it is easy to pick the wrong one. And the moment you want the &lt;em&gt;whole duplicate row&lt;/em&gt; rather than just the duplicated key, the query you Googled stops being the query you need. &lt;code&gt;GROUP BY&lt;/code&gt; collapses each group to one summary row; to keep every offending row you reach for a window function instead:&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="c1"&gt;-- Keep the full rows, tag the extras&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
  &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;ROW_NUMBER&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
           &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;email&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="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rn&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;customers&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;WHERE&lt;/span&gt; &lt;span class="n"&gt;rn&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Same question — "where are my duplicates?" — two structurally different queries, and which one you want depends on whether you need the &lt;em&gt;count&lt;/em&gt; or the &lt;em&gt;rows&lt;/em&gt;. It is a yes/no question wearing a SQL costume, and the costume changes every time.&lt;/p&gt;

&lt;h2&gt;
  
  
  Ask in English — then read the SQL it ran
&lt;/h2&gt;

&lt;p&gt;This is exactly the case for asking in plain English and &lt;em&gt;reading the SQL it generates&lt;/em&gt;. Not because SQL is beneath you — because the grain matters here and you want to verify it. "Which customers appear more than once by email?" should hand you back both the rows and the &lt;code&gt;GROUP BY email HAVING COUNT(*) &amp;gt; 1&lt;/code&gt; it ran, so you can confirm it grouped on the column you meant before you trust the count. A chat model can write you that query; it cannot run it against your data, and if you paste rows into a prompt and ask it to count, it will confidently hallucinate the tally.&lt;/p&gt;

&lt;p&gt;(That is the half we built &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt; for: ask the duplicate question in English over a Postgres it provisions, or one you already run via a signed-in connect, and get the rows plus the compiled SQL. Honest split — it &lt;em&gt;reports&lt;/em&gt; duplicates with a read-only query; which row to keep and how to merge is a write you run deliberately, and matching is exact, not fuzzy.)&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>analytics</category>
    </item>
    <item>
      <title>Your text-to-SQL eval is lying: the gateway returns HTTP 200 with the error in the body</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Wed, 12 Aug 2026 01:29:12 +0000</pubDate>
      <link>https://dev.to/omer_hochman/your-text-to-sql-eval-is-lying-the-gateway-returns-http-200-with-the-error-in-the-body-4i8i</link>
      <guid>https://dev.to/omer_hochman/your-text-to-sql-eval-is-lying-the-gateway-returns-http-200-with-the-error-in-the-body-4i8i</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/http-200-error-in-body/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;We were reading a text-to-SQL benchmark score that looked too low. Seven questions per 150-question run came back tagged &lt;code&gt;no_sql&lt;/code&gt; — the model, we thought, had simply failed to produce a query. On a frontier lane that is a jarring number. So we opened the raw responses, and the model had failed to produce &lt;em&gt;nothing&lt;/em&gt;. The HTTP status was &lt;code&gt;200 OK&lt;/code&gt;. The body was an error.&lt;/p&gt;

&lt;h2&gt;
  
  
  A 200 is a promise the gateway makes before it knows the answer
&lt;/h2&gt;

&lt;p&gt;This is a property of gateways, not a bug in one vendor. When you call an LLM through an aggregator like OpenRouter, two machines are involved: the gateway you connect to, and the upstream provider it routes your request to. The gateway has to decide its HTTP status &lt;em&gt;when it starts streaming a response to you&lt;/em&gt; — and at that moment the upstream request may still be in flight. So it commits to &lt;code&gt;200 OK&lt;/code&gt;, opens the stream, and then the upstream call rate-limits, times out, or errors. The status line already went out. The only place left to report the failure is the response body.&lt;/p&gt;

&lt;p&gt;OpenRouter documents exactly this: an error can arrive with a &lt;code&gt;200&lt;/code&gt; status and a top-level &lt;code&gt;error&lt;/code&gt; object &lt;em&gt;instead of&lt;/em&gt; &lt;code&gt;choices&lt;/code&gt; (&lt;a href="https://openrouter.ai/docs/api/reference/errors-and-debugging" rel="noopener noreferrer"&gt;errors reference&lt;/a&gt;). The shape looks like a normal completion envelope right up until you go looking for the content that isn't there.&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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;HTTP/&lt;/span&gt;&lt;span class="mf"&gt;1.1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;OK&lt;/span&gt;&lt;span class="w"&gt;  &lt;/span&gt;&lt;span class="err"&gt;←&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;lies&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;"error"&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="nl"&gt;"code"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;429&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"message"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"Provider returned error"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"metadata"&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="nl"&gt;"provider_name"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&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="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="err"&gt;//&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;no&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"choices"&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;—&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;the&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;completion&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;never&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;happened&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why res.ok quietly corrupts an eval
&lt;/h2&gt;

&lt;p&gt;The natural client is a two-branch one: if &lt;code&gt;res.ok&lt;/code&gt;, parse the completion; otherwise, it's an infrastructure error — pause, back off, retry. That branch is where the damage happens. A &lt;code&gt;200&lt;/code&gt; with an error body takes the &lt;em&gt;success&lt;/em&gt; branch. Your parser reaches for &lt;code&gt;choices[0].message.content&lt;/code&gt;, finds nothing, and hands back an empty string. Downstream, an empty completion is indistinguishable from "the model answered but produced no SQL" — so it gets scored as a &lt;strong&gt;wrong answer&lt;/strong&gt;.&lt;/p&gt;

&lt;p&gt;That single misclassification does two bad things to a benchmark. It undercounts your engine's true accuracy — the model was never given a chance to answer, yet it eats the loss. And it hides a real capacity problem — those seven failures were rate-limits the harness should have paused and retried, not quality losses to investigate. You end up staring at planner prompts trying to fix an accuracy gap that is actually an outage in disguise.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: res.ok is necessary, not sufficient
&lt;/h2&gt;

&lt;p&gt;Inspect the body for a top-level &lt;code&gt;error&lt;/code&gt; before you trust the &lt;code&gt;choices&lt;/code&gt;. A response is only a real completion if the transport succeeded &lt;em&gt;and&lt;/em&gt; the payload carries content. Everything else is infrastructure — classify it as such so your retry logic and your metrics both see it correctly.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;json&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;

&lt;span class="c1"&gt;// A 200 is not enough — the error can ride inside it.&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;ok&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;status&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;code&lt;/span&gt; &lt;span class="o"&gt;??&lt;/span&gt; &lt;span class="nx"&gt;res&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
  &lt;span class="c1"&gt;// 429 → capacity: pause + retry, don't score it&lt;/span&gt;
  &lt;span class="c1"&gt;// 5xx → provider error: retryable, still not a quality loss&lt;/span&gt;
  &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UpstreamError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;error&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;

&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;sql&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;choices&lt;/span&gt;&lt;span class="p"&gt;?.[&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;]?.&lt;/span&gt;&lt;span class="nx"&gt;message&lt;/span&gt;&lt;span class="p"&gt;?.&lt;/span&gt;&lt;span class="nx"&gt;content&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;if &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="nx"&gt;sql&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;throw&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;UpstreamError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;empty completion&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;In our own eval harness this was a seven-line change to the response classifier, and it moved the frontier lane's ceiling immediately: seven &lt;code&gt;no_sql&lt;/code&gt; "losses" per 150-question run reclassified from &lt;em&gt;engine failure&lt;/em&gt; to &lt;em&gt;capacity pause&lt;/em&gt;, which the tail-retry already covers. The accuracy number stopped lying, and the retry logic started catching the failures it was written for.&lt;/p&gt;

&lt;h2&gt;
  
  
  The general rule
&lt;/h2&gt;

&lt;p&gt;Any time you talk to a model through a gateway — an aggregator, a proxy, a load balancer, your own edge worker — the HTTP status describes the &lt;em&gt;hop you completed&lt;/em&gt;, not the &lt;em&gt;work you asked for&lt;/em&gt;. The two can disagree, and they disagree exactly when things are going wrong, which is the worst time to be blind. Read the body before you believe the status. If you're building an eval or an agent on top of a routed LLM, this one check is the difference between a metric you can trust and a metric that flatters your infrastructure by blaming your model.&lt;/p&gt;

&lt;p&gt;(This is one of the classifier rules behind &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt;, the data layer you ask in English: a rate-limited upstream is paused and retried, not counted as a query the engine couldn't answer — so the accuracy we report is the engine's, not the gateway's.)&lt;/p&gt;

</description>
      <category>llm</category>
      <category>api</category>
      <category>debugging</category>
    </item>
    <item>
      <title>The text-to-SQL demo takes an afternoon. The other 90% is why you should buy it.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Tue, 11 Aug 2026 01:46:04 +0000</pubDate>
      <link>https://dev.to/omer_hochman/the-text-to-sql-demo-takes-an-afternoon-the-other-90-is-why-you-should-buy-it-4iko</link>
      <guid>https://dev.to/omer_hochman/the-text-to-sql-demo-takes-an-afternoon-the-other-90-is-why-you-should-buy-it-4iko</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/text-to-sql-build-vs-buy/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The demo really is an afternoon. Pull the table definitions out of &lt;code&gt;information_schema&lt;/code&gt;, template them into a prompt with the user's question, call a model, run whatever SQL comes back, render the rows. Every stack has a tutorial for this now, and they all work — "let our users ask their data in English" goes from ticket to working prototype before the day ends. That's the 10%.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other 90% shows up after the first real user
&lt;/h2&gt;

&lt;p&gt;The prototype's job was to produce SQL. The feature's job is to run model-authored SQL against your production database, on your users' behalf, unattended. Those are different jobs, and the gap between them is a stack of infrastructure the tutorial never mentions:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A validator that fails closed.&lt;/strong&gt; The model will eventually emit a write — a &lt;code&gt;DELETE&lt;/code&gt; inside a CTE, a &lt;code&gt;DROP&lt;/code&gt; behind a comment, a join onto a table the asker should never see. You need a parser-level allow-list that rejects everything except the reads you meant to permit, and rejects anything it can't parse. A regex denylist is the bug report you haven't received yet.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A plan cache keyed on question + schema version.&lt;/strong&gt; The same question shouldn't cost a model call twice, so you cache compiled plans — but a cached plan is only valid until the schema moves, so the key has to carry a schema fingerprint and invalidation becomes your problem. Skip this and every dashboard load bills you fresh tokens at p95 model latency.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;An eval harness over a labelled set.&lt;/strong&gt; Prompts get edited, models get swapped or silently updated, and NL→SQL accuracy moves when either happens. Without a scored question→gold-answer set you find the regression when a customer does. Building the harness is a project; keeping the labelled set honest as your schema evolves is a chore with no finish line.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;None of this is exotic — every piece is buildable. The catch is that every piece is &lt;em&gt;maintainable&lt;/em&gt;: production infrastructure with your on-call rotation's name on it, in service of a feature that probably isn't your product.&lt;/p&gt;

&lt;h2&gt;
  
  
  The honest build-vs-buy test
&lt;/h2&gt;

&lt;p&gt;The wrong question is "can I generate SQL from English?" Yes — in an afternoon, that's the point. The right question is "do I want to own that stack?" If natural-language querying &lt;em&gt;is&lt;/em&gt; your product — you're building a BI tool, a data platform, an agent framework — own it; the validator and the eval harness are your moat. If it's a reporting tab, a search box over each user's own rows, an in-app assistant — a feature inside a product that's about something else — buy the pipeline and embed it, the way you'd buy auth or email instead of running an SMTP server.&lt;/p&gt;

&lt;p&gt;(That second case is the one &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt; exists for: drop in one element or one &lt;code&gt;POST /v1/ask&lt;/code&gt;, the English compiles against the live schema, the compiled SQL is shown before anyone trusts it, reads pass a fail-closed allow-list, and the validator/cache/eval stack is our maintenance burden instead of yours. Honest limits: it's a hosted pipeline you embed, not a library you vendor — and "many users over their own rows" still means a database or an isolation scope per tenant, because per-user row-level security inside one shared database isn't shipped.)&lt;/p&gt;

&lt;p&gt;The general lesson: a demo prices the first afternoon; a feature prices the years after it. When an AI capability collapses the demo cost to nearly zero — and text-to-SQL has — the build-vs-buy decision doesn't disappear. It just moves to the part of the stack the demo never showed you.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>ai</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Your offline LLM eval isn't measuring your model — it's measuring your rate limits</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Sat, 08 Aug 2026 01:16:33 +0000</pubDate>
      <link>https://dev.to/omer_hochman/your-offline-llm-eval-isnt-measuring-your-model-its-measuring-your-rate-limits-2ph0</link>
      <guid>https://dev.to/omer_hochman/your-offline-llm-eval-isnt-measuring-your-model-its-measuring-your-rate-limits-2ph0</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/offline-llm-eval-rate-limits/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Our small NL-to-SQL benchmark — twenty questions, one gold query each, scored by executing the SQL and comparing result sets — came back &lt;strong&gt;17/20&lt;/strong&gt; on a greedy pass. An immediate second run on the same commit, drawing three samples per question instead of one, came back &lt;strong&gt;6/20&lt;/strong&gt;, with 14 of the 20 questions returning no SQL at all. Ninety seconds apart, same engine, same prompts, an eleven-answer collapse.&lt;/p&gt;

&lt;p&gt;Nothing regressed. The engine behind &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt; runs on a chain of free-tier LLM providers, and the second run tripled the request volume the first run had already spent. The providers got tired. The score didn't measure the model's reasoning — it measured the moment the free quota ran out.&lt;/p&gt;

&lt;h2&gt;
  
  
  The failure signature: instant, empty, off the books
&lt;/h2&gt;

&lt;p&gt;The tell is in the error tally, not the score. Those 14 no-SQL answers were &lt;code&gt;circuit_open&lt;/code&gt; fast-fails: an earlier 429 had opened the provider's circuit breaker for its &lt;code&gt;Retry-After&lt;/code&gt; window, so the call failed before any tokens were generated — the p50 latency of a failing question was ~0 ms. A model that reasons badly takes seconds to be wrong; a rate limit is wrong instantly. When your failures are instant, you are measuring availability, not accuracy.&lt;/p&gt;

&lt;p&gt;On a multi-provider chain the collapse compounds: the 429 opens one breaker, the chain falls through to the next provider (also cooling down), and within a few questions every attempt fails without reaching a model. We hit the same wall at scale — our first full 500-question BIRD dispatch scored a dismal 0.214, and 246 of its 283 no-SQL failures were breaker fast-fails. We discarded the number. It measured the wall, not the engine.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three rules that keep availability out of the accuracy number
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Throttle to measure reasoning.&lt;/strong&gt; Spacing questions ~4 s apart keeps the run inside the free tiers' request rates. Our first clean throttled pass scored 21/23 — consistent with the healthy 17/20, nowhere near the starved 6/20. Slower, but the number means what it claims to mean.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Budget-stop and resume; don't push through.&lt;/strong&gt; When every attempt in a stretch fails with &lt;code&gt;rate_limited&lt;/code&gt; or &lt;code&gt;circuit_open&lt;/code&gt; after one bounded capacity wait, stop scoring and write a checkpoint keyed on the commit SHA. A full 500-question pass now runs as a handful of ~15-minute windows resumed across the day, instead of one starved marathon that scores the outage.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Keep the smoke test away from the powered run.&lt;/strong&gt; The quick greedy smoke and the windowed canonical run drain the same shared quota; back-to-back, the second one measures the first one's exhaust. Anything else that borrows the free chain — for us, an e2e suite whose driver is an LLM — belongs on a different day than a full eval.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The general lesson: if a benchmark number moves more between 9:00 and 9:02 than it does between two commits, read the error tally before the diff. An execution-accuracy score is only meaningful over questions that actually reached a model, so report attempted-versus-total next to the headline number — and treat instant failures as a capacity problem to engineer around (throttles, breakers, resumable windows), not a reasoning regression to bisect.&lt;/p&gt;

</description>
      <category>llm</category>
      <category>ai</category>
      <category>testing</category>
    </item>
    <item>
      <title>Your text-to-SQL model isn't as wrong as your benchmark says. The gold SQL is.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Fri, 07 Aug 2026 01:23:04 +0000</pubDate>
      <link>https://dev.to/omer_hochman/your-text-to-sql-model-isnt-as-wrong-as-your-benchmark-says-the-gold-sql-is-p16</link>
      <guid>https://dev.to/omer_hochman/your-text-to-sql-model-isnt-as-wrong-as-your-benchmark-says-the-gold-sql-is-p16</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/bird-gold-noise-distinct/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You run BIRD-dev, read an execution accuracy of 0.512, and the instinct is immediate: start writing planner directives to close the gap. We had the same instinct. Before acting on it we did one thing that changed the whole plan — we bucketed the losses. Not skimmed a few failures; tagged all 238 mismatches with a structural differ and counted what actually went wrong in each.&lt;/p&gt;

&lt;h2&gt;
  
  
  19% of our losses were one DISTINCT — added correctly
&lt;/h2&gt;

&lt;p&gt;The biggest bucket was startling: &lt;strong&gt;46 of 238 mismatches (19%)&lt;/strong&gt; differ from the gold SQL only by a &lt;code&gt;DISTINCT&lt;/code&gt; the model added and gold didn't. &lt;code&gt;COUNT(DISTINCT customer_id)&lt;/code&gt; where gold wrote &lt;code&gt;COUNT(*)&lt;/code&gt;. &lt;code&gt;SELECT DISTINCT x&lt;/code&gt; where gold wrote a plain &lt;code&gt;SELECT x&lt;/code&gt;. Read the pairs one by one and a large share of them are the model being &lt;em&gt;more&lt;/em&gt; correct than the annotation.&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="c1"&gt;-- Question: how many patients had an abnormal lab result?&lt;/span&gt;
&lt;span class="c1"&gt;-- Gold (BIRD annotation):&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;T1&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Patient&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;T1&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;Laboratory&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;T2&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;T1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;T2&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;WHERE&lt;/span&gt; &lt;span class="n"&gt;T2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'abnormal'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- one patient with 5 abnormal labs counts 5 times&lt;/span&gt;

&lt;span class="c1"&gt;-- Model:&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;T1&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;Patient&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;T1&lt;/span&gt;
&lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="n"&gt;Laboratory&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;T2&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;T1&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ID&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;T2&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;WHERE&lt;/span&gt; &lt;span class="n"&gt;T2&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;result&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'abnormal'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- counts patients — the thing the question asked for&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A patient-to-labs join is one-to-many. Counting &lt;em&gt;patients&lt;/em&gt; after that join needs &lt;code&gt;COUNT(DISTINCT T1.ID)&lt;/code&gt;; gold's bare &lt;code&gt;COUNT(T1.ID)&lt;/code&gt; over-counts the fan-out. Execution accuracy compares result sets, gold's result set is wrong, so the scorer marks the model &lt;strong&gt;wrong for being right&lt;/strong&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  This is not one benchmark having a bad day
&lt;/h2&gt;

&lt;p&gt;Independent measurement backs the pattern. The Kang lab at UIUC audited BIRD and found &lt;strong&gt;52.8% of instances carry annotation errors&lt;/strong&gt; (&lt;a href="https://arxiv.org/abs/2601.08778" rel="noopener noreferrer"&gt;VLDB 2026, arXiv:2601.08778&lt;/a&gt;) — wrong gold SQL, ambiguous questions, schema mismatches — and released a corrected evaluation set. When half the answer key has errors, the number the leaderboard prints is not a measurement of your engine. It's a measurement of your engine &lt;em&gt;and&lt;/em&gt; the answer key's noise, entangled.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap: optimizing into the noise
&lt;/h2&gt;

&lt;p&gt;Here is why this matters beyond bruised pride. If you chase the biggest loss bucket with a prompt directive — &lt;em&gt;"avoid DISTINCT unless explicitly requested"&lt;/em&gt; — the benchmark number goes up. You will feel good about it. And you will have taught your model to drop a &lt;code&gt;DISTINCT&lt;/code&gt; it should keep, silently degrading real-world answers over one-to-many joins, which are everywhere. That is overfitting to wrong gold: trading production correctness for benchmark points.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix: classify your loss mass before touching the prompt
&lt;/h2&gt;

&lt;p&gt;The differ that buckets losses is about a hundred lines and needs no LLM — parse both queries, diff the structure, tag the difference class (&lt;code&gt;extra_DISTINCT&lt;/code&gt;, extra column, wrong aggregate, wrong filter…), histogram the tags. Then apply one rule: &lt;strong&gt;only write a directive for a bucket where gold is right and the model is wrong.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;The same audit that disqualified the &lt;code&gt;DISTINCT&lt;/code&gt; bucket qualified another: 7 losses where the model concatenated two requested columns into one (&lt;code&gt;first_name || ' ' || last_name&lt;/code&gt;) against a gold that correctly returned two columns — 7 clean losses, zero gold-noise, and no winning query used the pattern. That bucket earned a directive, and re-scoring the de-concatenated predictions against the real databases confirmed it: wrong-to-right flips, zero regressions.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A benchmark number is a floor bounded by its gold quality, not a measure of your engine.&lt;/strong&gt; Before you optimize any metric, audit what it's actually counting — because past a point, the returns on prompt engineering aren't limited by your model. They're limited by the answer key.&lt;/p&gt;

&lt;p&gt;(This audit is standing practice for &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt;, the data layer you ask in English: every eval run's losses are bucketed structurally before any planner change ships, so the directives we write target real engine mistakes — not annotation noise.)&lt;/p&gt;

</description>
      <category>sql</category>
      <category>llm</category>
      <category>ai</category>
    </item>
    <item>
      <title>Your text-to-SQL accuracy is measured on schemas your users will never build</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Thu, 06 Aug 2026 01:23:38 +0000</pubDate>
      <link>https://dev.to/omer_hochman/your-text-to-sql-accuracy-is-measured-on-schemas-your-users-will-never-build-32b2</link>
      <guid>https://dev.to/omer_hochman/your-text-to-sql-accuracy-is-measured-on-schemas-your-users-will-never-build-32b2</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/text-to-sql-accuracy-schemas-your-users-never-build/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every text-to-SQL engine publishes the same two numbers: BIRD and Spider. Ours are not flattering — the strict-$0 free-model chain behind nlqdb currently scores &lt;strong&gt;0.52&lt;/strong&gt; on BIRD Mini-Dev and &lt;strong&gt;0.19&lt;/strong&gt; on the Spider 2.0-lite SQLite subset. We track both weekly, against a pinned baseline, with a paired significance test, because those benchmarks are the honesty instrument of this field: hard, public, and comparable to every research paper.&lt;/p&gt;

&lt;p&gt;But look at what they actually measure. BIRD's databases are real-world dumps — dozens of tables, cryptic column names, dirty values, questions that hinge on external knowledge notes. Spider 2.0 is enterprise-analytics scale on purpose; its authors built it because models had gotten too good at the small clean stuff. Both are the right kind of hard for a research leaderboard. Neither looks anything like the database a user of a product like ours ever touches.&lt;/p&gt;

&lt;h2&gt;
  
  
  The schema your users build is the one you never scored
&lt;/h2&gt;

&lt;p&gt;Our users describe a goal in plain English and get a small, freshly-provisioned Postgres: a form-submissions table, a four-table agent-memory schema, a webhook event log. Five tables, honest column names, no fifteen-year accretion of legacy views. That shape — the one 100% of production queries actually run against — had zero rows in either benchmark. Which means the headline accuracy number described a workload we don't serve, in both directions: it undercounts what users experience, and it can hide regressions on the queries they really ask.&lt;/p&gt;

&lt;h2&gt;
  
  
  persona-bench: gold queries over the ICP shape, same scorer
&lt;/h2&gt;

&lt;p&gt;So we added a third benchmark and open-sourced it into the repo: &lt;strong&gt;persona-bench&lt;/strong&gt;, 23 hand-authored question/gold-SQL pairs over the two schema shapes our personas actually build (a SaaS app DB and the agent-memory preset). Three rules kept it honest:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Same execution-accuracy scorer as BIRD/Spider.&lt;/strong&gt; A result-set match against gold, not an LLM judge — the number is comparable across all three datasets.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Gold is literal-date, never relative.&lt;/strong&gt; No &lt;code&gt;now()&lt;/code&gt; in gold SQL, so a question's answer never drifts with the clock and a run today reproduces a run in March.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A gold-executability invariant runs first.&lt;/strong&gt; Every gold query must execute against the fixture and return non-degenerate rows before any model is scored. A benchmark with broken gold measures nothing — the ruler gets checked before the thing it measures.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The result: the same free chain that scores 0.52 on BIRD and 0.19 on Spider scores &lt;strong&gt;0.96 (22/23)&lt;/strong&gt; on persona-bench. That is not a brag — small clean schemas are exactly where NL-to-SQL is easy, which is the point: the difficulty distribution of a benchmark is a product decision, and defaulting to the academic one silently pins your roadmap to someone else's workload. The one persona-bench miss told us more about our planner than fifty BIRD misses over schemas we will never host.&lt;/p&gt;

&lt;h2&gt;
  
  
  Keep both numbers
&lt;/h2&gt;

&lt;p&gt;The failure mode to avoid is swapping the hard public benchmark for your flattering private one. We report all three: BIRD and Spider for comparability and as the hard floor that keeps us honest, persona-bench for the workload users hit. Two caveats we attach every time: 23 questions is small — one flipped answer moves the score about 4 points — and a benchmark you author yourself has an obvious conflict of interest, which the executability invariant and publishing the fixture mitigate but do not remove.&lt;/p&gt;

&lt;p&gt;If you ship an NL-to-data feature, the take-away is one afternoon of work: write twenty gold queries over the schema shape your users actually have, score them with the same execution-accuracy check the papers use, and run it beside the public benchmarks — not instead of them. (&lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt; is the database you talk to; the harness, fixture, and all three scores live in the open repo.)&lt;/p&gt;

</description>
      <category>sql</category>
      <category>llm</category>
      <category>ai</category>
    </item>
    <item>
      <title>Every data tool shipped an MCP server this year. Your agent still can't build on most of them.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Mon, 03 Aug 2026 09:24:08 +0000</pubDate>
      <link>https://dev.to/omer_hochman/every-data-tool-shipped-an-mcp-server-this-year-your-agent-still-cant-build-on-most-of-them-4cn</link>
      <guid>https://dev.to/omer_hochman/every-data-tool-shipped-an-mcp-server-this-year-your-agent-still-cant-build-on-most-of-them-4cn</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/mcp-server-what-does-the-agent-own/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;MCP is the new "we have an API." Writing a competitor comparison recently, I went to mark "agent-callable" as our differentiator against an AI data-notebook tool — and stopped, because they'd shipped an MCP server too. So had the BI tool two rows up. The honest move was to concede the checkbox. But conceding it surfaced the real axis, and it's one worth naming.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two shapes of MCP server
&lt;/h2&gt;

&lt;p&gt;There are two shapes of MCP server, and they look identical in a feature matrix. The first wraps a &lt;strong&gt;destination app&lt;/strong&gt;: "ask my published notebook a question," "answer from my dashboard in Slack." The human's workflow, now reachable by an agent. The second exposes &lt;strong&gt;infrastructure the agent owns&lt;/strong&gt;: provision a database, write rows, query them, migrate the schema. Both speak MCP. Only the second lets an agent build something that outlives the conversation.&lt;/p&gt;

&lt;p&gt;The tell is to ask what the agent &lt;em&gt;owns&lt;/em&gt; after the call returns. If the answer is "a view into a human's analysis," that's a genuinely useful human-in-the-loop surface — and a dead end for an autonomous agent, because the agent can read but can't accumulate. It has nowhere to put the row it just computed. An agent that can query but not persist is a calculator, not a coworker.&lt;/p&gt;

&lt;h2&gt;
  
  
  "Does it have MCP" is the wrong question
&lt;/h2&gt;

&lt;p&gt;So the question to ask a tool's MCP server isn't "does it exist" — by 2026 it always does. It's &lt;strong&gt;"what does it let the agent own?"&lt;/strong&gt; Read-only over someone else's app, or a substrate the agent can write to and come back to. The matrix can't tell them apart; you have to read what the verbs actually do.&lt;/p&gt;

&lt;p&gt;(At &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt; the MCP verb &lt;code&gt;nlqdb_query&lt;/code&gt; materialises a Postgres on first reference — omit the database id with none provisioned and it creates one from the goal, so the agent gets a database it owns, not a window into ours. The comparison that prompted this is at &lt;a href="https://dev.to/vs/hex/"&gt;nlqdb vs Hex&lt;/a&gt;.)&lt;/p&gt;

</description>
      <category>mcp</category>
      <category>ai</category>
      <category>api</category>
    </item>
    <item>
      <title>Your agent's memory is a vector store. Ask it "how many" and watch it fall over.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Sun, 02 Aug 2026 09:20:23 +0000</pubDate>
      <link>https://dev.to/omer_hochman/your-agents-memory-is-a-vector-store-ask-it-how-many-and-watch-it-fall-over-4ha8</link>
      <guid>https://dev.to/omer_hochman/your-agents-memory-is-a-vector-store-ask-it-how-many-and-watch-it-fall-over-4ha8</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/agent-memory-vector-store-aggregation-gap/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The standard agent-memory build is an afternoon of work: embed every fact worth keeping, upsert it into a vector store, and before each reply pull the top-k most similar memories back into context. And for what it's built for, it works. Ask "what did this user say about the Berlin migration" and the right snippets come back, ranked by cosine distance. Recall is solved enough that it feels like &lt;em&gt;memory&lt;/em&gt; is solved.&lt;/p&gt;

&lt;p&gt;Then the agent has been running for a month, and you ask its memory a different kind of question: "how many users asked about pricing this month?" "Average deal size per stage?" "Top 10 topics I logged, ranked by count?" The store dutifully returns the twenty memories most &lt;em&gt;similar to the question text&lt;/em&gt;, the LLM eyeballs them, and you get a confident, specific, wrong number.&lt;/p&gt;

&lt;h2&gt;
  
  
  Recall is similarity. Reporting is aggregation.
&lt;/h2&gt;

&lt;p&gt;Nothing malfunctioned — the two questions want different machines. A vector store's primitive is nearest-neighbour search: embed the query, rank stored vectors by distance, return the top-k, optionally narrowed by a metadata filter. That is the whole contract. There is no &lt;code&gt;COUNT&lt;/code&gt;, no &lt;code&gt;GROUP BY&lt;/code&gt;, no &lt;code&gt;JOIN&lt;/code&gt;, no &lt;code&gt;HAVING&lt;/code&gt; — a similarity engine ships no query planner, and even the metadata filter only narrows candidates &lt;em&gt;around&lt;/em&gt; the approximate search, so what comes back is still a ranking of similar items, never a computed result set.&lt;/p&gt;

&lt;p&gt;"How many" has to touch &lt;strong&gt;every matching row&lt;/strong&gt;. If the agent logged 4,000 memories and top-k is 20, the context the LLM sees is structurally incapable of producing the count — and an LLM doing arithmetic over a retrieved sample is a hallucination generator, not a query engine. The failure is quiet, too: the answer arrives fluent and plausible, and nothing flags that it was computed from half a percent of the data.&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="c1"&gt;-- "top topics this month, ranked by count" is not a similarity query.&lt;/span&gt;
&lt;span class="c1"&gt;-- It's this — and it must scan every matching row, not the top-k:&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;topic&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;AS&lt;/span&gt; &lt;span class="n"&gt;mentions&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;memories&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;created_at&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;date_trunc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'month'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;())&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;topic&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;mentions&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;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;So the split worth keeping: when the question is &lt;strong&gt;"find what's like this"&lt;/strong&gt; — RAG context, related-document lookup, fuzzy recall over conversation — a vector store is exactly the right machine, and a managed one like Pinecone is genuinely good at it. When the question is &lt;strong&gt;"count, group, or rank what I stored"&lt;/strong&gt;, the memory needs to be typed rows behind a real query planner. Neither machine substitutes for the other, and bolting a bigger LLM onto the first one doesn't turn it into the second.&lt;/p&gt;

&lt;p&gt;They compose cleanly: vector store as the recall layer, relational store as the analytical one. That second layer is what &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt; is — a real Postgres the agent provisions itself over MCP and queries in plain English, with the compiled SQL shown so you can read exactly what ran. The honest caveat cuts the other way too: nlqdb ships no embedding search, so it is not your recall layer. Pick the store per question shape — the full side-by-side is at &lt;a href="https://dev.to/vs/pinecone/"&gt;nlqdb vs Pinecone&lt;/a&gt;.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>database</category>
      <category>postgres</category>
      <category>sql</category>
    </item>
    <item>
      <title>Your database scales to zero. Your retry loop doesn't know that.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Wed, 29 Jul 2026 17:24:40 +0000</pubDate>
      <link>https://dev.to/omer_hochman/your-database-scales-to-zero-your-retry-loop-doesnt-know-that-373e</link>
      <guid>https://dev.to/omer_hochman/your-database-scales-to-zero-your-retry-loop-doesnt-know-that-373e</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/serverless-db-cold-start-retry/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Serverless Postgres is wonderful until the first query after an idle spell. A free-tier Neon branch parks its compute after ~5 minutes of no traffic; the next query has to wake it, and that first connection can fail while the compute spins back up. We had a retry loop in front of it — three attempts, textbook. It made things worse. The retries fired so fast they all hit the same cold connection, and the user got a crisp &lt;code&gt;db_unreachable&lt;/code&gt; about 40 ms after the database had already started waking up.&lt;/p&gt;

&lt;h2&gt;
  
  
  One retry loop, two completely different failures
&lt;/h2&gt;

&lt;p&gt;Our &lt;code&gt;/v1/ask&lt;/code&gt; pipeline has three stages that can each throw a transient: &lt;code&gt;route&lt;/code&gt; (an LLM classifier call), &lt;code&gt;plan&lt;/code&gt; (the LLM emits SQL), and &lt;code&gt;exec&lt;/code&gt; (the query hits the database). We wrapped all three in the same helper — three attempts, then surface. The mistake was assuming a retry is a retry. It isn't. The &lt;em&gt;right delay between attempts depends on why the stage failed&lt;/em&gt;, and the two failure modes here want opposite things.&lt;/p&gt;

&lt;p&gt;When an LLM call fails, it's usually one provider returning a 5xx or a rate-limit. The fix is to fail over to a sibling provider — and you want to do that &lt;strong&gt;immediately&lt;/strong&gt;, because the sibling is a different machine that is already warm. Any delay is dead time on a spinner. But when &lt;code&gt;exec&lt;/code&gt; fails on a cold serverless branch, the fix is the opposite: the &lt;em&gt;same&lt;/em&gt; endpoint needs a moment of wall-clock time to become reachable. Retrying it instantly just re-dials a socket that isn't listening yet.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug: instant retries replay a cold connection
&lt;/h2&gt;

&lt;p&gt;With a zero-delay loop, all three exec attempts land inside the same few tens of milliseconds — before the compute has finished resuming. Three cold dials, three failures, then a &lt;code&gt;502 db_unreachable&lt;/code&gt; handed to the user. The surface then &lt;em&gt;lies&lt;/em&gt;: it says we couldn't reach the database, when in truth we reached it three times in a row while it was mid-boot and gave up ~600 ms before it would have answered. The retries didn't absorb the transient. They burned through the budget the transient needed.&lt;/p&gt;

&lt;h2&gt;
  
  
  Back off the stage that needs wall-time, not the one that doesn't
&lt;/h2&gt;

&lt;p&gt;The fix is one option on the retry helper: an optional per-stage backoff. &lt;code&gt;route&lt;/code&gt; and &lt;code&gt;plan&lt;/code&gt; keep retrying instantly — their transient is a provider that fails over to a warm sibling with no benefit to waiting. Only &lt;code&gt;exec&lt;/code&gt; opts into a delay, and only because its dominant transient is a compute that needs to wake up.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="c1"&gt;// route + plan: an LLM provider 5xx'd. Fail over to a sibling&lt;/span&gt;
&lt;span class="c1"&gt;// provider on the NEXT attempt, instantly — waiting buys nothing,&lt;/span&gt;
&lt;span class="c1"&gt;// the sibling is a different machine and already warm.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withStageRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;plan&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;planOnce&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;// exec: the dominant transient is a scale-to-zero Postgres compute&lt;/span&gt;
&lt;span class="c1"&gt;// that needs wall-time to resume. Back off so attempts 2/3 land warm.&lt;/span&gt;
&lt;span class="k"&gt;await&lt;/span&gt; &lt;span class="nf"&gt;withStageRetry&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;exec&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="nx"&gt;runQuery&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="na"&gt;backoffMs&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;failedAttempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="mi"&gt;300&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="o"&gt;**&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;failedAttempt&lt;/span&gt; &lt;span class="o"&gt;-&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt; &lt;span class="c1"&gt;// 300ms, then 600ms&lt;/span&gt;
&lt;span class="p"&gt;});&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The timeline: attempt 1 at t=0 (cold, fails), wait 300 ms, attempt 2 at t=300 ms, wait 600 ms, attempt 3 at t=900 ms. A free-tier Neon compute resumes inside that ≤900 ms window, so attempt 3 lands warm and the query returns — the user never sees the cold start. The happy path is untouched: a database that's already awake answers on attempt 1 and never sleeps a millisecond. The backoff is pure failure-path cost, paid only by the request that would otherwise have failed outright.&lt;/p&gt;

&lt;h2&gt;
  
  
  Prove it without a flaky database
&lt;/h2&gt;

&lt;p&gt;A cold-start bug you can only reproduce by waiting five minutes for a real branch to idle is a bug you will never keep fixed. So we made the delay injectable — the helper takes a &lt;code&gt;sleep&lt;/code&gt; function, real timers in prod, a fake clock in the test. The test models a branch that stays unreachable until t=700 ms: &lt;strong&gt;without&lt;/strong&gt; backoff, all three instant attempts land before 700 ms and the request surfaces &lt;code&gt;db_unreachable&lt;/code&gt;; &lt;strong&gt;with&lt;/strong&gt; the exec backoff, attempt 3 lands at t=900 ms and recovers. Same code path, deterministic, no real database, no five-minute wait. The regression can't silently come back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;A retry policy is not one setting — it's one per failure mode.&lt;/strong&gt; Before you pick a backoff, ask what the retry is actually waiting &lt;em&gt;for&lt;/em&gt;. Waiting for a different machine to answer? Don't wait — fail over now. Waiting for the &lt;em&gt;same&lt;/em&gt; machine to wake up? The wait is the entire point, and retrying without it is just three ways to fail at the same instant. The error message that says "unreachable" is often really saying "you asked 600 ms too early."&lt;/p&gt;

&lt;p&gt;(This runs under every question you ask &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt;, the data layer you talk to in English: the LLM stages fail over between providers instantly, the database stage backs off just long enough for a scale-to-zero branch to wake, and a cold start turns into a slightly slower answer instead of an error.)&lt;/p&gt;

</description>
      <category>database</category>
      <category>serverless</category>
      <category>webdev</category>
    </item>
    <item>
      <title>AI made the internal-tool builder faster. It didn't ask whether you needed the tool.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Tue, 28 Jul 2026 17:19:16 +0000</pubDate>
      <link>https://dev.to/omer_hochman/ai-made-the-internal-tool-builder-faster-it-didnt-ask-whether-you-needed-the-tool-32ea</link>
      <guid>https://dev.to/omer_hochman/ai-made-the-internal-tool-builder-faster-it-didnt-ask-whether-you-needed-the-tool-32ea</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/ai-internal-tool-builder-faster/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every low-code platform now has an AI layer. Describe the app, it scaffolds the screens against your schema. Ask in English, it writes the SQL. Point an agent at it and it plans, calls tools, and queries your data with guardrails. This is real and it's good — the thing that used to take an afternoon of dragging components and wiring queries takes a prompt.&lt;/p&gt;

&lt;h2&gt;
  
  
  What got faster is building the tool
&lt;/h2&gt;

&lt;p&gt;But notice what got faster: &lt;em&gt;building the tool&lt;/em&gt;. The output is still a destination — an internal app a human opens, logs into, and reads. AI shortened the path from "I need a dashboard" to "I have a dashboard." It didn't question the premise that the answer to a data question is a dashboard you build.&lt;/p&gt;

&lt;p&gt;A lot of the time it isn't. The data question lives &lt;em&gt;inside&lt;/em&gt; a product you're already shipping — "show this customer their last five orders," "what did this account spend this quarter" — and the honest deliverable isn't a separate admin app, it's an answer rendered inline, on the page the user is already on. Or the asker isn't a human at all: it's an agent that needs to provision a database, write to it, and query it programmatically on every request, with no UI in the loop ever. Neither of those wants a built tool. They want a backend primitive.&lt;/p&gt;

&lt;h2&gt;
  
  
  Builder or backend primitive
&lt;/h2&gt;

&lt;p&gt;That's the fork. A builder — even an AI-supercharged one — assumes a human will assemble and operate the result. A backend primitive assumes nobody will: you embed one element or call one API, pass an English goal, and get typed rows back. (At &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt; we took the second side on purpose — English compiles to SQL over a Postgres the product or agent &lt;em&gt;provisions and owns&lt;/em&gt;, writes diff-previewed, no app to assemble first — which is exactly why we don't ship a drag-drop canvas. Different job.) The builder wins when the deliverable genuinely is a standalone tool a team will run; the primitive wins when the answer belongs in the product, or the asker is code.&lt;/p&gt;

&lt;p&gt;Lesson: when an AI feature makes an old workflow 10× faster, check whether it made the &lt;em&gt;workflow&lt;/em&gt; faster or the &lt;em&gt;outcome&lt;/em&gt; faster. Scaffolding an internal tool faster is a real win — but if what you actually needed was the answer in your own app, or a database your agent stands up itself, the fastest builder is still building something you didn't need.&lt;/p&gt;

</description>
      <category>ai</category>
      <category>database</category>
      <category>webdev</category>
    </item>
    <item>
      <title>Top N per group is the query `LIMIT` can't write</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Mon, 27 Jul 2026 17:19:13 +0000</pubDate>
      <link>https://dev.to/omer_hochman/top-n-per-group-is-the-query-limit-cant-write-57eb</link>
      <guid>https://dev.to/omer_hochman/top-n-per-group-is-the-query-limit-cant-write-57eb</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/top-n-rows-per-group/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;You want the top 3 best-selling products &lt;em&gt;in each category&lt;/em&gt;. Or the most recent order &lt;em&gt;per customer&lt;/em&gt;. Or the highest-scoring attempt &lt;em&gt;per user&lt;/em&gt;. The English is so plain it feels like it should compile to something you already know — &lt;code&gt;ORDER BY revenue DESC LIMIT 3&lt;/code&gt; — and that is exactly the trap. &lt;code&gt;LIMIT&lt;/code&gt; caps the &lt;em&gt;whole result set&lt;/em&gt;. Ask it for the top 3 and it hands you the 3 best rows across every category combined, not 3 per category. The word "per" quietly moved the query somewhere &lt;code&gt;LIMIT&lt;/code&gt; cannot follow.&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="c1"&gt;-- What you wrote (wrong): 3 rows total, not 3 per category&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products&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;revenue&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;h2&gt;
  
  
  "Per group" is a partition, not a limit
&lt;/h2&gt;

&lt;p&gt;This is the classic "greatest-N-per-group" problem, and the reason it gets re-Googled every time is that the correct shape looks nothing like the question. You are not limiting rows — you are &lt;em&gt;ranking within each group and keeping the top of each rank&lt;/em&gt;. That is a window function: number the rows inside each partition, then filter to the rank you want.&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="c1"&gt;-- Rank inside each category, then keep the top 3 of each&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;
&lt;span class="k"&gt;FROM&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;category&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;ROW_NUMBER&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="n"&gt;OVER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
           &lt;span class="k"&gt;PARTITION&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;category&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;revenue&lt;/span&gt; &lt;span class="k"&gt;DESC&lt;/span&gt;
         &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;rn&lt;/span&gt;
  &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;products&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;WHERE&lt;/span&gt; &lt;span class="n"&gt;rn&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&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;p&gt;The &lt;code&gt;PARTITION BY&lt;/code&gt; is the "per category" the English asked for; the &lt;code&gt;ORDER BY&lt;/code&gt; inside the window is the "best-selling"; the outer &lt;code&gt;WHERE rn &amp;lt;= 3&lt;/code&gt; is the "top 3." You cannot filter on &lt;code&gt;rn&lt;/code&gt; in the same SELECT that computes it — window functions are evaluated after &lt;code&gt;WHERE&lt;/code&gt; — so it has to be a subquery (or a CTE). That structural jump, from a flat query to a nested ranked one, is the whole difficulty. Nothing here is hard; it just doesn't resemble the sentence you started with.&lt;/p&gt;

&lt;h2&gt;
  
  
  The decision hiding in "top 3": what happens to ties
&lt;/h2&gt;

&lt;p&gt;There is a second choice buried in that query, and it is the one that bites in production. If two products tie for third place by revenue, do you want exactly 3 rows, or all the rows tied at rank 3? &lt;code&gt;ROW_NUMBER()&lt;/code&gt; breaks ties arbitrarily and gives you exactly 3 — but which of the tied rows it drops is undefined unless your &lt;code&gt;ORDER BY&lt;/code&gt; is fully deterministic. &lt;code&gt;RANK()&lt;/code&gt; keeps every tied row (so "top 3" might return 4). &lt;code&gt;DENSE_RANK()&lt;/code&gt; keeps ties but doesn't skip rank numbers. Same English, three different answers, and the query never tells you which one you picked — you have to have decided.&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;ROW_NUMBER()&lt;/code&gt; — &lt;strong&gt;exactly N rows&lt;/strong&gt; per group; ties broken by the &lt;code&gt;ORDER BY&lt;/code&gt; (add a tiebreak column, or the drop is nondeterministic).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;RANK()&lt;/code&gt; — &lt;strong&gt;every tied row&lt;/strong&gt; at the cutoff is included, and rank numbers skip after a tie (1, 2, 2, 4).&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;DENSE_RANK()&lt;/code&gt; — &lt;strong&gt;ties included, no gaps&lt;/strong&gt; in the numbering (1, 2, 2, 3).&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Ask in English — then read the SQL it ran
&lt;/h2&gt;

&lt;p&gt;This is a good case for asking in plain English and &lt;em&gt;reading the query back&lt;/em&gt;, precisely because the failure mode is silent: the wrong-&lt;code&gt;LIMIT&lt;/code&gt; version runs fine and returns rows — just the wrong ones — and the tie behaviour never announces itself. "Top 3 products per category by revenue" should hand you back both the ranked rows and the &lt;code&gt;ROW_NUMBER() OVER (PARTITION BY category ORDER BY revenue DESC)&lt;/code&gt; it compiled, so you can confirm it partitioned by the column you meant and see how ties resolve before you trust the numbers in a deck.&lt;/p&gt;

&lt;p&gt;(That is the half we built &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt; for: ask the top-N question in English over a Postgres it provisions, or one you already run via a signed-in connect, and get the ranked rows plus the compiled window-function SQL. Honest split — it returns a read-only ranked answer, not a live leaderboard that updates on its own; and if you want ties included, say so and it switches &lt;code&gt;ROW_NUMBER&lt;/code&gt; to &lt;code&gt;RANK&lt;/code&gt; or &lt;code&gt;DENSE_RANK&lt;/code&gt;.)&lt;/p&gt;

&lt;p&gt;The general lesson: when a question says "per" or "in each," the grain moved from the result set to a group inside it, and set-level tools like &lt;code&gt;LIMIT&lt;/code&gt; stop applying. Reach for a window function — and decide the tie rule on purpose, because the query will pick one for you whether or not you meant to.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>database</category>
      <category>webdev</category>
    </item>
    <item>
      <title>We published 20 blog posts and never shipped a feed. Nothing could subscribe.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Sun, 26 Jul 2026 17:32:18 +0000</pubDate>
      <link>https://dev.to/omer_hochman/we-published-20-blog-posts-and-never-shipped-a-feed-nothing-could-subscribe-1pln</link>
      <guid>https://dev.to/omer_hochman/we-published-20-blog-posts-and-never-shipped-a-feed-nothing-could-subscribe-1pln</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/blog-without-a-feed-is-a-dead-end/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;For weeks the blog grew and the reach didn't. We published post after post — engineering notes, SQL traps, honest comparisons — and every one rendered fine at its own URL. Anyone who already knew the URL could read it. That was the whole reach: people we'd already reached. The pages were live, and publishing felt done. It wasn't. Publishing a page and publishing a &lt;em&gt;feed&lt;/em&gt; are two different acts, and we'd only done the first.&lt;/p&gt;

&lt;h2&gt;
  
  
  Publishing ends when a machine can subscribe, not when a page renders
&lt;/h2&gt;

&lt;p&gt;A web page is something a human pulls up once. A feed is something a machine subscribes to and pulls forever. Everything that redistributes your writing — every reader, every aggregator, every cross-post integration — is a machine, and a machine needs a stable URL that lists your posts in a format it can parse. That URL is your RSS or Atom feed. Without it, your content has doors a human can walk through one at a time and no door a machine can automate. The blog isn't dead; it's just sealed to everything that would spread it.&lt;/p&gt;

&lt;h2&gt;
  
  
  What a feed unlocks that a page can't
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Feed readers.&lt;/strong&gt; Feedly, Inoreader, NetNewsWire — the people most likely to follow an engineering blog live in a reader. No feed URL, no way to follow. You are invisible to your most loyal potential audience.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Auto-import to high-authority venues.&lt;/strong&gt; dev.to, Medium, and Hashnode all have an 'import your posts from RSS' setting: point it at your feed and every new post auto-mirrors to a domain with far more indexing authority than yours — with a &lt;code&gt;rel=canonical&lt;/code&gt; link pointing back, so the SEO credit still accrues to your copy. This is the part that actually moves the yield needle, and it is impossible without a feed URL.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Everything else that speaks RSS.&lt;/strong&gt; Newsletter tools, Slack/Discord post bots, IFTTT/Zapier automations — the long tail of redistribution all keys off one feed URL. Ship it once and every one of these becomes a config field instead of a project.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;Without a feed, each of those becomes a manual copy-paste: open the venue, paste the title, paste the body, fix the formatting, set the canonical link by hand. It works for exactly as long as your discipline holds, and then it quietly stops — the third week you're busy, the re-posts don't happen, and nobody notices because there's no error, just silence.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix was about 40 lines and no dependency
&lt;/h2&gt;

&lt;p&gt;The obvious move is to reach for a plugin — for us, &lt;code&gt;@astrojs/rss&lt;/code&gt;. We didn't. Our posts already live in one typed data file that the sitemap and the &lt;code&gt;llms.txt&lt;/code&gt; endpoint both read; a feed is a third reader of the same array. So it's a hand-rolled endpoint that maps each post to an RSS &lt;code&gt;&amp;lt;item&amp;gt;&lt;/code&gt;, the exact no-dependency pattern the sitemap already uses, and it runs on Cloudflare Workers with nothing to bundle.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight typescript"&gt;&lt;code&gt;&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="kd"&gt;type&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;APIRoute&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;astro&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;import&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="nx"&gt;BLOG_POSTS&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;from&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;../data/blog&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;// Titles/descriptions are free text → XML-escape before embedding.&lt;/span&gt;
&lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;esc&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kr"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
  &lt;span class="nx"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;amp;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;amp;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;lt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;lt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;replace&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sr"&gt;/&amp;gt;/g&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;&amp;amp;gt;&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="k"&gt;export&lt;/span&gt; &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;GET&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="nx"&gt;APIRoute&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;items&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nx"&gt;BLOG_POSTS&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;map&lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&amp;gt;&lt;/span&gt;
    &lt;span class="s2"&gt;`&amp;lt;item&amp;gt;\n`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`  &amp;lt;title&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;esc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;title&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/title&amp;gt;\n`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`  &amp;lt;link&amp;gt;https://nlqdb.com/blog/&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;slug&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;/&amp;lt;/link&amp;gt;\n`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`  &amp;lt;pubDate&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Date&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;`&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;date&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;T00:00:00Z`&lt;/span&gt;&lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;toUTCString&lt;/span&gt;&lt;span class="p"&gt;()}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/pubDate&amp;gt;\n`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`  &amp;lt;description&amp;gt;&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nf"&gt;esc&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nx"&gt;description&lt;/span&gt;&lt;span class="p"&gt;)}&lt;/span&gt;&lt;span class="s2"&gt;&amp;lt;/description&amp;gt;\n`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`&amp;lt;/item&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
  &lt;span class="p"&gt;).&lt;/span&gt;&lt;span class="nf"&gt;join&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\n&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

  &lt;span class="kd"&gt;const&lt;/span&gt; &lt;span class="nx"&gt;body&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s2"&gt;`&amp;lt;?xml version="1.0" encoding="UTF-8"?&amp;gt;\n`&lt;/span&gt; &lt;span class="o"&gt;+&lt;/span&gt;
    &lt;span class="s2"&gt;`&amp;lt;rss version="2.0"&amp;gt;&amp;lt;channel&amp;gt;\n&lt;/span&gt;&lt;span class="p"&gt;${&lt;/span&gt;&lt;span class="nx"&gt;items&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="s2"&gt;\n&amp;lt;/channel&amp;gt;&amp;lt;/rss&amp;gt;`&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

  &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="k"&gt;new&lt;/span&gt; &lt;span class="nc"&gt;Response&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="nx"&gt;body&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="na"&gt;headers&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;Content-Type&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="dl"&gt;"&lt;/span&gt;&lt;span class="s2"&gt;application/rss+xml; charset=utf-8&lt;/span&gt;&lt;span class="dl"&gt;"&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
  &lt;span class="p"&gt;});&lt;/span&gt;
&lt;span class="p"&gt;};&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two details earn their place. First, &lt;strong&gt;XML-escape the free text&lt;/strong&gt; — titles and descriptions are prose, not known-safe URLs, so an unescaped &lt;code&gt;&amp;amp;&lt;/code&gt; or &lt;code&gt;&amp;lt;&lt;/code&gt; produces a feed that won't parse (this is the one thing the sitemap endpoint gets to skip, because it only ever emits URL paths). Second, add one &lt;code&gt;&amp;lt;link rel="alternate" type="application/rss+xml"&amp;gt;&lt;/code&gt; to the site's &lt;code&gt;&amp;lt;head&amp;gt;&lt;/code&gt; so browsers and readers &lt;em&gt;autodiscover&lt;/em&gt; the feed from any page — the endpoint exists, but this is what lets a reader find it by pasting your homepage instead of hunting for &lt;code&gt;/rss.xml&lt;/code&gt;.&lt;/p&gt;

&lt;h2&gt;
  
  
  The rule
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Count the doors into your content, not the pages.&lt;/strong&gt; A page count measures how much you wrote; a feed measures how many ways that writing can leave your site without you lifting a finger. A post nobody can subscribe to is a post nobody re-shares — and the gap doesn't show up as an error, it shows up as a referral graph that stays flat while the sitemap keeps growing. If you've been publishing for weeks and the reach isn't compounding, check whether a machine can even subscribe.&lt;/p&gt;

&lt;p&gt;(This blog is the daily build log for &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt;, the data layer you query in plain English. The feed above is real — every post here is auto-importable, &lt;code&gt;rel=canonical&lt;/code&gt; back to this domain, from the same typed file that renders the page you're reading.)&lt;/p&gt;

</description>
      <category>webdev</category>
      <category>blogging</category>
      <category>seo</category>
    </item>
  </channel>
</rss>
