<?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>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>
    <item>
      <title>You don't need a backend to store form submissions. You need a place to ask "how many."</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Fri, 24 Jul 2026 22:19:46 +0000</pubDate>
      <link>https://dev.to/omer_hochman/you-dont-need-a-backend-to-store-form-submissions-you-need-a-place-to-ask-how-many-3kec</link>
      <guid>https://dev.to/omer_hochman/you-dont-need-a-backend-to-store-form-submissions-you-need-a-place-to-ask-how-many-3kec</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/store-form-submissions-without-a-backend/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Every landing page hits the same wall around hour three: the signup form works, but where do the emails actually &lt;em&gt;go&lt;/em&gt;? The reflex is to stand up a server and a database for what is, honestly, an &lt;code&gt;INSERT&lt;/code&gt; and an occasional &lt;code&gt;COUNT&lt;/code&gt;. So most people reach for a form service instead — and that solves storage, but quietly splits your data from your questions. The submissions live in someone else's dashboard; the moment you want "signups per day since launch" or "which referrer actually converted," you're exporting a CSV and pivoting it by hand.&lt;/p&gt;

&lt;h2&gt;
  
  
  Two problems hiding in one, with different shapes
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Capture&lt;/strong&gt; is a write — a small one, and it genuinely doesn't need a server: an insert call from the page's own &lt;code&gt;fetch&lt;/code&gt;, or a ten-line serverless function, is enough, as long as the write key isn't sitting in your client HTML. &lt;strong&gt;Reporting&lt;/strong&gt; is a read, and it's the part that actually wants a database — because "how many per day," "top source this week," and "conversion by campaign" are aggregations, and aggregations want a query planner, not a spreadsheet and a human.&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;-- capture: one small insert per submission (no server required)&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;signups&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;referrer&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="k"&gt;VALUES&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;2&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="c1"&gt;-- reporting: the part that actually wants a query planner&lt;/span&gt;
&lt;span class="k"&gt;SELECT&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;'day'&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="k"&gt;AS&lt;/span&gt; &lt;span class="k"&gt;day&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;signups&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;signups&lt;/span&gt;
&lt;span class="k"&gt;GROUP&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;
&lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="k"&gt;day&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The mistake is picking a tool that's great at the write and leaves you alone with the read. A form service nails capture and hands you a list. A spreadsheet-via-webhook nails capture and hands you a tab you pivot by hand. What you want is for the place the rows land to also be a place you can &lt;em&gt;ask questions of&lt;/em&gt; — ideally in plain English, so the day-one question ("did anyone sign up?") and the week-two question ("which tweet drove it?") are the same two-second action, not a data chore.&lt;/p&gt;

&lt;p&gt;That's the shape worth looking for, whatever you build it on: &lt;strong&gt;storage you can also interrogate.&lt;/strong&gt; Each submission is a row in a real Postgres, and the reporting question is one English goal — &lt;code&gt;signups grouped by day with a count&lt;/code&gt; — that compiles to SQL you can read before you trust it.&lt;/p&gt;

&lt;p&gt;That's how &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt; works, but the point isn't the tool — it's refusing to let your form data land somewhere you can't ask it anything. The honest caveat that applies to &lt;em&gt;any&lt;/em&gt; version of this: the public read widget isn't a write endpoint, so capture still goes through a key the browser never sees, and email delivery plus spam filtering stay your front-end's and your ESP's job. Storage isn't the hard part. Not being able to ask your own data a question is.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>webdev</category>
      <category>database</category>
      <category>tutorial</category>
    </item>
    <item>
      <title>Your sitemap is advertising redirects — and your canonical tag points at one</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Tue, 21 Jul 2026 01:31:25 +0000</pubDate>
      <link>https://dev.to/omer_hochman/your-sitemap-is-advertising-redirects-and-your-canonical-tag-points-at-one-2860</link>
      <guid>https://dev.to/omer_hochman/your-sitemap-is-advertising-redirects-and-your-canonical-tag-points-at-one-2860</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/sitemap-advertising-redirects/?utm_source=devto" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Run &lt;code&gt;curl -sI&lt;/code&gt; over the URLs in your own sitemap. We did, and 27 of ours answered &lt;strong&gt;307 Temporary Redirect&lt;/strong&gt; — every route on the site. The canonical tag was worse: each page declared a canonical URL that redirected to the page itself. We were telling crawlers, on every single page, that the authoritative copy lives one hop away.&lt;/p&gt;

&lt;h2&gt;
  
  
  How a static host quietly forks every URL in two
&lt;/h2&gt;

&lt;p&gt;The mechanics are mundane, which is why this survives review. A static build writes each route as &lt;code&gt;route/index.html&lt;/code&gt;. The host serves &lt;code&gt;/route/&lt;/code&gt; directly — and answers the bare &lt;code&gt;/route&lt;/code&gt; with a 307 to the slash form. So every route on the site has two URLs: one real, one a redirect. Which one your HTML advertises depends on whoever typed the link.&lt;/p&gt;

&lt;p&gt;Our templates typed the bare form everywhere it matters most: the &lt;code&gt;canonical&lt;/code&gt; tag, &lt;code&gt;og:url&lt;/code&gt;, the sitemap generator, and &lt;code&gt;llms.txt&lt;/code&gt; — the four surfaces whose entire job is to name the authoritative URL. All four advertised the redirect.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why a self-referential redirecting canonical is worth fixing
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A canonical pointing at a redirect is a mixed signal.&lt;/strong&gt; The tag exists to end URL ambiguity; making crawlers resolve a hop to find the 'canonical' copy reintroduces exactly the ambiguity it was meant to close.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sitemap URLs are supposed to be final.&lt;/strong&gt; Search engines tolerate redirects there, but every hop is a chance for a URL to get indexed in the form you didn't pick — and split whatever authority the page earns across two spellings.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;AI crawlers fetch exactly what you wrote.&lt;/strong&gt; &lt;code&gt;llms.txt&lt;/code&gt; consumers and answer-engine bots are literal; an extra 307 on every fetch is latency you chose, on the surface built for machines.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  The fix is a policy plus one helper, not a link hunt
&lt;/h2&gt;

&lt;p&gt;Chasing individual links is the wrong altitude. The durable fix was three moves: pick the slash form as policy (&lt;code&gt;trailingSlash: "always"&lt;/code&gt;, so the build itself rejects the other spelling); route every emitted URL — canonical, &lt;code&gt;og:url&lt;/code&gt;, sitemap, &lt;code&gt;llms.txt&lt;/code&gt; — through one path-normalize helper in the head layout and URL generators; and re-audit with &lt;code&gt;curl -sI&lt;/code&gt; over every sitemap URL, expecting nothing but 200s.&lt;/p&gt;

&lt;p&gt;The one-helper rule is what made the follow-up cheap: once advertised URLs were normalized in one place, sweeping the remaining 1,100+ bare-path hrefs inside page bodies was a mechanical pass, and a link check in the build now fails on any regression. (&lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt; is the database you talk to; this is one of the engineering notes from building it in the open.)&lt;/p&gt;

&lt;p&gt;The general lesson: every surface that names a URL — canonical, &lt;code&gt;og:url&lt;/code&gt;, sitemap, &lt;code&gt;llms.txt&lt;/code&gt;, internal hrefs — should call the same normalize function. If two of them can disagree about your own address, they eventually will, and you'll advertise the disagreement to every crawler that visits.&lt;/p&gt;

</description>
      <category>seo</category>
      <category>webdev</category>
      <category>sitemap</category>
    </item>
    <item>
      <title>NOT IN returned zero rows. It wasn't your data — it was one NULL.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Mon, 20 Jul 2026 04:24:27 +0000</pubDate>
      <link>https://dev.to/omer_hochman/not-in-returned-zero-rows-it-wasnt-your-data-it-was-one-null-4inj</link>
      <guid>https://dev.to/omer_hochman/not-in-returned-zero-rows-it-wasnt-your-data-it-was-one-null-4inj</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/not-in-subquery-null-trap/" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;"Which customers never placed an order?" is a question you ask constantly — products never sold, users with no login this month, invoices with no payment. It's a set difference, and the obvious query is a quiet trap:&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="o"&gt;*&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;WHERE&lt;/span&gt; &lt;span class="n"&gt;id&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&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;customer_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="p"&gt;);&lt;/span&gt;   &lt;span class="c1"&gt;-- returns nothing. why?&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;If a single &lt;code&gt;customer_id&lt;/code&gt; in that subquery is NULL, you get &lt;strong&gt;zero rows&lt;/strong&gt; — no error, no warning. Here's why: &lt;code&gt;NOT IN (a, b, NULL)&lt;/code&gt; expands to &lt;code&gt;id &amp;lt;&amp;gt; a AND id &amp;lt;&amp;gt; b AND id &amp;lt;&amp;gt; NULL&lt;/code&gt;. That last comparison is never &lt;code&gt;true&lt;/code&gt; — comparing anything to NULL is &lt;code&gt;unknown&lt;/code&gt; — so the whole &lt;code&gt;AND&lt;/code&gt; chain can never be &lt;code&gt;true&lt;/code&gt;, and every row is rejected. One NULL in the inner table silently empties your result.&lt;/p&gt;

&lt;h2&gt;
  
  
  The two shapes that actually work
&lt;/h2&gt;



&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="c1"&gt;-- LEFT JOIN ... IS NULL: keep the customers that found no matching order&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="o"&gt;*&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;c&lt;/span&gt;
&lt;span class="k"&gt;LEFT&lt;/span&gt; &lt;span class="k"&gt;JOIN&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;ON&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;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&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;o&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;IS&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;

&lt;span class="c1"&gt;-- NOT EXISTS: a correlated anti-join, NULL-safe by construction&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="n"&gt;customers&lt;/span&gt; &lt;span class="k"&gt;c&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="mi"&gt;1&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;WHERE&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;customer_id&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;c&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;p&gt;Both return the same rows for a plain anti-join. &lt;code&gt;NOT EXISTS&lt;/code&gt; stops at the first match and never trips over NULLs. The &lt;code&gt;LEFT JOIN ... IS NULL&lt;/code&gt; form is just as correct — but if the join key isn't unique it can multiply rows &lt;em&gt;before&lt;/em&gt; the filter, so know your grain. What neither of them does is silently lie to you the way &lt;code&gt;NOT IN&lt;/code&gt; does.&lt;/p&gt;

&lt;p&gt;The rule worth keeping: reach for &lt;code&gt;NOT EXISTS&lt;/code&gt; (or &lt;code&gt;LEFT JOIN ... IS NULL&lt;/code&gt;) for "rows with no match," and treat &lt;code&gt;NOT IN (subquery)&lt;/code&gt; as a smell unless you're certain the subquery is NULL-free.&lt;/p&gt;

&lt;p&gt;If you'd rather not re-derive which shape is safe every time: &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt; takes "customers who never placed an order" in English, compiles the NULL-safe anti-join, runs it read-only, and shows the SQL so you can confirm it isn't a &lt;code&gt;NOT IN&lt;/code&gt;. Honest limit — it owns the Postgres it answers; bring-your-own-Postgres is signed-in only, not the public embed.&lt;/p&gt;

</description>
      <category>sql</category>
      <category>postgres</category>
      <category>database</category>
    </item>
    <item>
      <title>Zep gives my agent perfect recall. It still can't answer "average per group" about its own memory.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Sun, 19 Jul 2026 07:28:37 +0000</pubDate>
      <link>https://dev.to/omer_hochman/zep-gives-my-agent-perfect-recall-it-still-cant-answer-average-per-group-about-its-own-memory-3ae0</link>
      <guid>https://dev.to/omer_hochman/zep-gives-my-agent-perfect-recall-it-still-cant-answer-average-per-group-about-its-own-memory-3ae0</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/zep-recall-vs-analytical-agent-memory/" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;If you've wired up &lt;a href="https://www.getzep.com" rel="noopener noreferrer"&gt;Zep&lt;/a&gt; you know the pitch: it's the Context Lake — a temporal knowledge graph (Graphiti, 27k+ stars) that stores every fact your agent learns as a node with a validity window, resolves entities, and hands back the most relevant facts at query time. For &lt;em&gt;recall&lt;/em&gt; it's genuinely good, and it publishes benchmarks (LongMemEval, DMR) to prove it.&lt;/p&gt;

&lt;p&gt;But we kept hitting the same wall. Once the agent had logged a few hundred things, we wanted to ask questions &lt;em&gt;about&lt;/em&gt; the memory, not retrieve from it:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;"Top 10 topics I logged this month, ranked by count."&lt;/li&gt;
&lt;li&gt;"Average deal size per stage for enterprise customers."&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;A knowledge graph has no query planner. It returns relevant facts and hopes the LLM does the arithmetic — which is a hallucination generator, not a &lt;code&gt;GROUP BY&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The honest split (the full side-by-side lives at &lt;a href="https://dev.to/vs/zep/"&gt;nlqdb vs Zep&lt;/a&gt;): Zep wins on temporal validity, entity resolution, and vector recall over conversation. nlqdb wins when the agent needs to &lt;strong&gt;aggregate&lt;/strong&gt; its memory — it's a real Postgres the agent provisions and queries in English, so &lt;code&gt;GROUP BY / JOIN / HAVING&lt;/code&gt; actually work. They compose: Zep the recall layer, nlqdb the analytical store. Pick the one that matches the question you actually need answered.&lt;/p&gt;

&lt;p&gt;(Landscape facts verified 2026-06-19; both products' weaknesses are in the comparison, not just ours.)&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>database</category>
    </item>
    <item>
      <title>The NULL timestamp that broke a TTL sweep and a funnel metric at the same time</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Sat, 18 Jul 2026 10:20:18 +0000</pubDate>
      <link>https://dev.to/omer_hochman/the-null-timestamp-that-broke-a-ttl-sweep-and-a-funnel-metric-at-the-same-time-3lo1</link>
      <guid>https://dev.to/omer_hochman/the-null-timestamp-that-broke-a-ttl-sweep-and-a-funnel-metric-at-the-same-time-3lo1</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/null-timestamp-ttl-sweep-funnel-metric/" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;A row in our &lt;code&gt;databases&lt;/code&gt; registry has a &lt;code&gt;last_queried_at&lt;/code&gt; column. Two unrelated systems read it: a daily sweep that evicts anonymous DBs whose &lt;code&gt;last_queried_at&lt;/code&gt; is older than 90 days, and a funnel metric that counts "DBs that have ever returned an answer." Both quietly broke for the same reason, and the bug is worth sharing because it's a whole &lt;em&gt;class&lt;/em&gt; of mistake, not a one-off.&lt;/p&gt;

&lt;p&gt;We added the column in a migration that backfilled existing rows (&lt;code&gt;UPDATE … SET last_queried_at = updated_at WHERE last_queried_at IS NULL&lt;/code&gt;) — textbook. What we forgot: the &lt;code&gt;INSERT&lt;/code&gt; on the create path never set the column. So every row created &lt;em&gt;after&lt;/em&gt; the migration was &lt;code&gt;NULL&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Now watch both readers fail, differently:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;The sweep silently keeps everything.&lt;/strong&gt; &lt;code&gt;WHERE last_queried_at &amp;lt; :cutoff&lt;/code&gt; looks like it evicts old rows. But in SQL, &lt;code&gt;NULL &amp;lt; anything&lt;/code&gt; is &lt;code&gt;NULL&lt;/code&gt;, which is not &lt;code&gt;TRUE&lt;/code&gt;, so a &lt;code&gt;NULL&lt;/code&gt; row never matches a &lt;code&gt;&amp;lt;&lt;/code&gt; predicate. The age-based eviction became a no-op for every new row. No error, no log — the table just grows.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The metric silently reads zero.&lt;/strong&gt; "DBs that returned an answer" was &lt;code&gt;COUNT(*) WHERE last_queried_at IS NOT NULL&lt;/code&gt;. Every new row is &lt;code&gt;NULL&lt;/code&gt;, so the metric is pinned at 0 regardless of what users actually did. We nearly shipped a "fix" for a conversion problem that didn't exist — the &lt;em&gt;instrument&lt;/em&gt; was broken, not the funnel.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  Three takeaways
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;A backfill is not a default.&lt;/strong&gt; If a column needs a value, set it at write time (a &lt;code&gt;DEFAULT&lt;/code&gt;, or in every &lt;code&gt;INSERT&lt;/code&gt;). A one-time backfill fixes the past and nothing else.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;NULL is not "old" or "zero" — it's "unknown," and it poisons comparisons.&lt;/strong&gt; Any &lt;code&gt;&amp;lt;&lt;/code&gt; / &lt;code&gt;&amp;gt;&lt;/code&gt; / &lt;code&gt;!=&lt;/code&gt; against a nullable column has a third outcome you have to design for. &lt;code&gt;COALESCE&lt;/code&gt; at the read, or forbid the &lt;code&gt;NULL&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Before "fixing" a metric that reads 0, prove the instrument can ever read non-zero.&lt;/strong&gt; Ours structurally couldn't.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;(Context: this was in &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt;, a service that turns plain-English HTML components into SQL — the anonymous-DB sweep is how we keep the free tier's storage bounded. The fix was two lines: seed the column at create, re-run the backfill once.)&lt;/p&gt;

</description>
      <category>database</category>
      <category>metrics</category>
      <category>testing</category>
    </item>
    <item>
      <title>The redesign shipped. The smoke test kept walking the old UI.</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Thu, 16 Jul 2026 09:13:45 +0000</pubDate>
      <link>https://dev.to/omer_hochman/the-redesign-shipped-the-smoke-test-kept-walking-the-old-ui-47c8</link>
      <guid>https://dev.to/omer_hochman/the-redesign-shipped-the-smoke-test-kept-walking-the-old-ui-47c8</guid>
      <description>&lt;p&gt;&lt;em&gt;Originally published at &lt;a href="https://nlqdb.com/blog/smoke-test-walks-the-old-ui/" rel="noopener noreferrer"&gt;nlqdb.com/blog&lt;/a&gt;&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;Our acceptance walkers pin literal UI strings on purpose. A walker asserts the homepage placeholder reads &lt;em&gt;exactly&lt;/em&gt; what we ship, that a heading says the words we wrote, that the query composer is where we put it. Drift fails the walk loudly — and a loud failure the moment the surface changes underneath you is precisely the regression detector you want. Silent tolerance is how a broken flow ships green.&lt;/p&gt;

&lt;p&gt;Then three things happened in one week. A homepage redesign moved the goal input to a different page. A copy edit reworded a heading. The MCP catalog &lt;em&gt;additively&lt;/em&gt; grew two tools. None of these broke the product. All three broke the walkers — which dutifully reported &lt;strong&gt;0/9&lt;/strong&gt;, and kept reporting it for a week.&lt;/p&gt;

&lt;h2&gt;
  
  
  The trap isn't the literal assertions
&lt;/h2&gt;

&lt;p&gt;The reflex is to blame the pinned strings and loosen them into fuzzy matches. That's the wrong lesson. The literals did their job: the surface changed, the walk went red. The actual cost is that a red which &lt;em&gt;mixes&lt;/em&gt; "the product broke" with "the test went stale" takes a full manual triage to disentangle — and ours contained both at once. Two flows were red from pure test-drift. One flow was red from a real production wall. Same 0/9. You cannot tell which is which from the number, so every red costs you the same expensive human read regardless of whether anything is actually wrong.&lt;/p&gt;

&lt;h2&gt;
  
  
  Three notes that make pinned walkers pay off
&lt;/h2&gt;

&lt;ol&gt;
&lt;li&gt;Pinned literals are fine &lt;strong&gt;only if reds are triaged inside a bounded window&lt;/strong&gt;. A detector nobody reads within a day isn't a detector — it's drift accumulating interest until the next person can't tell a week-old copy edit from this-morning's outage.&lt;/li&gt;
&lt;li&gt;The failure detail must name the element &lt;strong&gt;and&lt;/strong&gt; the expectation. &lt;code&gt;placeholder was null, expected "Ask your data anything"&lt;/code&gt; is decidable from the artifact alone — you know instantly it's drift, not breakage. &lt;code&gt;failed at step 2&lt;/code&gt; forces you to re-run the whole walk by hand to find out.&lt;/li&gt;
&lt;li&gt;"A PR touching a walked surface re-runs the walker" was already our rule — and it was skipped, because it was a convention, not a gate. A convention without an enforcing check is a wish. Wire the walker into the surface's required checks, or accept the false-red debt &lt;em&gt;knowingly&lt;/em&gt; — but don't pretend a rule nobody enforces is protecting you.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;This is a testing-hygiene pattern, not a product feature: it's for anyone whose end-to-end suite asserts real rendered copy rather than test-ids. The literals are worth keeping — they catch the drift you'd otherwise ship. Just make the red &lt;em&gt;self-explaining&lt;/em&gt; and triage it on a clock, or the detector quietly becomes a week of noise that hides the one failure that mattered. nlqdb is a database you query in plain English; this is one of the measurement lessons from keeping our stranger-walk honest as the product underneath it moved.&lt;/p&gt;

</description>
      <category>testing</category>
      <category>e2e</category>
      <category>frontend</category>
    </item>
    <item>
      <title>Your LLM fused the two columns you asked for — and the eval marked it wrong</title>
      <dc:creator>Omer Hochman</dc:creator>
      <pubDate>Tue, 07 Jul 2026 20:43:35 +0000</pubDate>
      <link>https://dev.to/omer_hochman/your-llm-fused-the-two-columns-you-asked-for-and-the-eval-marked-it-wrong-5gge</link>
      <guid>https://dev.to/omer_hochman/your-llm-fused-the-two-columns-you-asked-for-and-the-eval-marked-it-wrong-5gge</guid>
      <description>&lt;p&gt;You ask a text-to-SQL model to "list the members' names". The benchmark's gold query returns &lt;code&gt;first_name, last_name&lt;/code&gt; — two columns. The model returns one: a helpfully assembled full name. Read side by side, the model's answer is arguably the better one. The scorer marks it wrong, every single time, and your engine number reads lower than the engine is.&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;-- gold: two columns&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;first_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;last_name&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;member&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;...;&lt;/span&gt;

&lt;span class="c1"&gt;-- model: one column, same information&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;first_name&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;' '&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;last_name&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;member&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="p"&gt;...;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  Why every execution-accuracy scorer does this
&lt;/h2&gt;

&lt;p&gt;Execution accuracy — the metric behind BIRD, Spider, and most private text-to-SQL evals — runs both queries and compares &lt;em&gt;positional value tuples&lt;/em&gt;: canonical BIRD literally compares &lt;code&gt;set(fetchall())&lt;/code&gt;. Column names are ignored on purpose (aliases shouldn't matter), which means the &lt;em&gt;shape&lt;/em&gt; of each row is all that's left. A one-column result can never equal a two-column gold, no matter how right the content is.&lt;/p&gt;

&lt;p&gt;This is the mirror image of the extra-columns failure everyone already knows about — where the model SELECTs a helpful extra field and the added column breaks tuple equality. Fusing two requested columns into one is exactly as fatal as adding one nobody asked for. The scorer has no notion of "close": the tuple matches or it doesn't.&lt;/p&gt;

&lt;h2&gt;
  
  
  The loss is real, measurable, and lopsided
&lt;/h2&gt;

&lt;p&gt;We bucketed a full 500-question BIRD-dev run of our free-lane engine with a structural differ before touching the prompt. The concatenation signature was unambiguous:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;7 of 238 losses&lt;/strong&gt; concatenated columns where the gold query kept them separate.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;0 of 256 wins&lt;/strong&gt; used &lt;code&gt;||&lt;/code&gt; at all — the operator appeared only in losing answers.&lt;/li&gt;
&lt;li&gt;Gold itself used &lt;code&gt;||&lt;/code&gt; in &lt;strong&gt;1 of 500&lt;/strong&gt; questions.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;That last line is the decision. When a construct shows up in 3% of your losses, none of your wins, and almost none of the gold answers, discouraging it is near-pure upside — there is essentially nothing on the other side of the trade to regress.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix is one sentence in the planner prompt
&lt;/h2&gt;

&lt;p&gt;No fine-tune, no reranker: one projection directive — &lt;em&gt;return each requested attribute as its own column unless the question explicitly asks for a single combined string&lt;/em&gt;. Before shipping it we de-concatenated the 7 flagged predictions by hand and re-executed them against the real SQLite databases to get a deterministic ceiling: &lt;strong&gt;+3 questions&lt;/strong&gt; flip wrong-to-right, zero regressions. The live re-measure confirmed the direction: run-wide &lt;code&gt;||&lt;/code&gt; concatenations dropped from 7 to 3, and 2 of the 3 ceiling questions flipped to matches.&lt;/p&gt;

&lt;p&gt;The general method matters more than this one directive: bucket your loss mass with a structural differ &lt;em&gt;first&lt;/em&gt;, compute the deterministic ceiling of a candidate fix &lt;em&gt;second&lt;/em&gt;, and only then edit the prompt. Prompt directives written from vibes overfit; directives written from a 7-losses/0-wins histogram are as close to a free lunch as engine work gets.&lt;/p&gt;

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

&lt;p&gt;The model's job is to match the shape of the answer, not to make it pretty. A helpful concatenation is still a wrong result set — and unless you bucket your losses, you'll never notice that some of your "wrong" answers were the model being more helpful than your gold.&lt;/p&gt;

&lt;p&gt;(This directive now ships in &lt;a href="https://nlqdb.com" rel="noopener noreferrer"&gt;nlqdb&lt;/a&gt;, the data layer you ask in English. The eval harness that found it — structural mismatch classifier, deterministic-ceiling re-scorer — runs against public BIRD/Spider plus our own ICP-shaped persona benchmark, so prompt changes land with a measured delta, not a hunch.)&lt;/p&gt;




&lt;p&gt;&lt;em&gt;Originally published on &lt;a href="https://nlqdb.com/blog/llm-concatenates-columns-text-to-sql" rel="noopener noreferrer"&gt;nlqdb.com&lt;/a&gt;.&lt;/em&gt;&lt;/p&gt;

</description>
      <category>sql</category>
      <category>llm</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
