<?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: Rasik</title>
    <description>The latest articles on DEV Community by Rasik (@rasikraj01).</description>
    <link>https://dev.to/rasikraj01</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%2F4046587%2F2342556f-82b9-454c-9ec7-da841efff800.jpg</url>
      <title>DEV Community: Rasik</title>
      <link>https://dev.to/rasikraj01</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/rasikraj01"/>
    <language>en</language>
    <item>
      <title>Your Database MCP Is Lying to You</title>
      <dc:creator>Rasik</dc:creator>
      <pubDate>Sat, 25 Jul 2026 20:26:41 +0000</pubDate>
      <link>https://dev.to/rasikraj01/your-database-mcp-is-lying-to-you-2dkk</link>
      <guid>https://dev.to/rasikraj01/your-database-mcp-is-lying-to-you-2dkk</guid>
      <description>&lt;p&gt;Here's a conversation I've had more than once.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Me:&lt;/strong&gt; How many orders use express delivery?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent:&lt;/strong&gt; &lt;em&gt;(runs `SELECT count(&lt;/em&gt;) FROM orders WHERE details -&amp;gt;&amp;gt; 'express_delivery' = 'true'`)*&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent:&lt;/strong&gt; None. No orders in the table use express delivery.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The real answer was 2,317.&lt;/p&gt;

&lt;p&gt;The agent didn't hallucinate. It ran a real query against a real database and reported the real result. The query was just &lt;em&gt;wrong&lt;/em&gt; — and this is the part that matters — nothing anywhere in the stack was capable of noticing.&lt;/p&gt;




&lt;h2&gt;
  
  
  Why it lies
&lt;/h2&gt;

&lt;p&gt;Ask your MCP server to describe the table and it tells you the truth, as far as it goes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; column_name | data_type
-------------+-----------
 id          | uuid
 details     | jsonb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;jsonb&lt;/code&gt;. That's the entire contract. That column could hold &lt;code&gt;{}&lt;/code&gt; in every row or a nine-level fulfillment tree with 400 distinct paths, and &lt;code&gt;information_schema&lt;/code&gt; genuinely cannot tell you which — because as far as Postgres's type system is concerned, &lt;code&gt;jsonb&lt;/code&gt; is one scalar type. The structure lives in the value, not the type. Nobody ever told the catalog.&lt;/p&gt;

&lt;p&gt;So the model does the only thing available to it: pattern-matches a plausible key name out of the column name and whatever's floating around the conversation. It writes &lt;code&gt;express_delivery&lt;/code&gt;. The data says:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;fulfillment.shippingV2.expressDelivery
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Snake case or camel case. Flat or nested. Singular or plural. Three independent coin flips, and it needed to win all three.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why nothing catches it
&lt;/h2&gt;

&lt;p&gt;If a wrong guess threw an error, none of this would matter. The agent would see the error, adjust, try again. That loop works fine — it's most of what agents do all day.&lt;/p&gt;

&lt;p&gt;But &lt;code&gt;-&amp;gt;&lt;/code&gt; on a missing key returns &lt;code&gt;NULL&lt;/code&gt;. &lt;code&gt;NULL = 'true'&lt;/code&gt; evaluates to &lt;code&gt;NULL&lt;/code&gt;. &lt;code&gt;NULL&lt;/code&gt; filters the row out. So a wrong guess returns &lt;strong&gt;zero rows&lt;/strong&gt;, which is a pixel-perfect impostor of "no data matches."&lt;/p&gt;

&lt;p&gt;That's the whole failure. Not a bad model, not a bad prompt — a severed feedback loop. Every wrong guess looks identical to a correct query on an empty result set, so the model has no signal to self-correct. It reports its guess as a finding, with the serene confidence of a weather presenter. And then somebody puts the number in a deck.&lt;/p&gt;

&lt;p&gt;Worth sitting with: you have exactly the same problem. You just have Slack history and a colleague named Dave to compensate. The agent has neither. (Dave, in my experience, has also left the company.)&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that doesn't work
&lt;/h2&gt;

&lt;p&gt;The obvious move is to hand the model some rows and let it work the shape out:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;20&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;This is worse than it looks. Twenty 40KB documents is tens of thousands of tokens spent on a &lt;em&gt;bad&lt;/em&gt; inference — the model sees whichever paths happened to occur in those particular twenty rows, with no frequency information, no type consistency check, and no concept of what it missed. Optional fields look mandatory. Rare fields don't exist at all. And you've burned half your context window getting there.&lt;/p&gt;

&lt;p&gt;You're shipping the data to the reasoning. The data is four orders of magnitude bigger than the answer.&lt;/p&gt;

&lt;h2&gt;
  
  
  The fix that works
&lt;/h2&gt;

&lt;p&gt;Make the database do the aggregation and hand the model the conclusion.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;jsonb&lt;/code&gt; isn't text — it's a parsed binary structure, and Postgres can iterate it without re-parsing anything. Three primitives are all you need: &lt;code&gt;jsonb_each&lt;/code&gt;, &lt;code&gt;jsonb_array_elements&lt;/code&gt;, and &lt;code&gt;jsonb_typeof&lt;/code&gt;. Wire them into a recursive CTE and the entire walk runs server-side:&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;WITH&lt;/span&gt; &lt;span class="k"&gt;RECURSIVE&lt;/span&gt; &lt;span class="n"&gt;walk&lt;/span&gt; &lt;span class="k"&gt;AS&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sample&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;LATERAL&lt;/span&gt; &lt;span class="n"&gt;jsonb_each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;jsonb_typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'object'&lt;/span&gt;
    &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+&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;walk&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
    &lt;span class="k"&gt;CROSS&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="k"&gt;LATERAL&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'.'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;
        &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;jsonb_each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;jsonb_typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'object'&lt;/span&gt;
      &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
        &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'[]'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;
        &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;jsonb_array_elements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;el&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;jsonb_typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'array'&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
       &lt;span class="n"&gt;array_agg&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;jsonb_typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&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;types&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="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;rid&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;occurrences&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;walk&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;path&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That &lt;code&gt;CROSS JOIN LATERAL&lt;/code&gt; in the recursive term isn't stylistic. SQL allows exactly one self-reference in a recursive term, and objects and arrays are two different descent rules — unioning them inside a lateral subquery is what makes the whole thing legal.&lt;/p&gt;

&lt;p&gt;What comes back is one row per distinct path instead of one row per document:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                  path                   |   types   | occurrences
-----------------------------------------+-----------+-------------
 fulfillment.shippingV2.enabled          | {boolean} |         973
 fulfillment.shippingV2.expressDelivery  | {boolean} |           3
 lineItems[].sku                         | {string}  |         998
 status                                  | {string}  |        1000
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four lines. A few hundred tokens. The mystery from the top of this post solves itself on sight.&lt;/p&gt;

&lt;p&gt;Three things turn that from clever into practical:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Sample by page, not by row.&lt;/strong&gt; &lt;code&gt;TABLESAMPLE SYSTEM&lt;/code&gt; — or &lt;code&gt;system_rows&lt;/code&gt; if the contrib extension is installed — reads random &lt;em&gt;pages&lt;/em&gt; instead of scanning, so cost scales with your sample rather than your table. &lt;code&gt;ORDER BY random()&lt;/code&gt; is a full scan wearing a trench coat; it reads all 52 million rows to hand you a thousand.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Stop on convergence.&lt;/strong&gt; Profile in batches, count newly-discovered paths per batch, stop when a few consecutive batches turn up nothing new. Production JSON is emitted by a finite amount of application code, so the discovery curve is steep — most tables plateau by row 300–500.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Report your uncertainty.&lt;/strong&gt; Sampling &lt;em&gt;will&lt;/em&gt; miss the key that appears in 3 rows out of 52 million, and the consumer needs to know that:&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="nl"&gt;"sampling"&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;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"system_rows"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rows_scanned"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"estimated_total_rows"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;52000000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"converged"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"missing_mass"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.004&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"note"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"frequency_pct is computed over the sample, not the whole table"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;missing_mass&lt;/code&gt; there is Good–Turing: singleton paths divided by documents sampled. &lt;code&gt;0.004&lt;/code&gt; means roughly one document in 250 carries a path you haven't seen — comfortably fine to write queries against. &lt;code&gt;0.20&lt;/code&gt; means the table is heterogeneous and your map isn't trustworthy yet. A profiler that reports "complete" after touching 0.003% of a table is worse than no profiler at all, because it launders a known unknown into an unknown unknown.&lt;/p&gt;

&lt;h2&gt;
  
  
  Wire it in as a tool
&lt;/h2&gt;

&lt;p&gt;Expose that as an MCP tool and the loop closes:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"profile_json_columns"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Infer json/jsonb structure (key paths, types, frequency, examples); block-samples to convergence."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"schema"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Required&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"table"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Required&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
    &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"column"&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;The model calls it, gets the real path map, and writes &lt;code&gt;details #&amp;gt;&amp;gt; '{fulfillment,shippingV2,expressDelivery}'&lt;/code&gt; on the first attempt instead of the fourth. Database does the aggregation, model gets the conclusion, context window survives.&lt;/p&gt;

&lt;h2&gt;
  
  
  The bug report nobody asked for
&lt;/h2&gt;

&lt;p&gt;Profile a column and you get one for free. When a single path comes back as &lt;code&gt;"types": ["number", "string"]&lt;/code&gt;, you have both &lt;code&gt;{"amount": 1999}&lt;/code&gt; and &lt;code&gt;{"amount": "19.99"}&lt;/code&gt; sitting in production — two writers, two conventions, one storing integer cents and one storing dollar strings. A latent cast error stacked on a latent 100x pricing bug, discovered by asking an entirely unrelated question.&lt;/p&gt;

&lt;p&gt;Frequency reads as a design review too. A path at 100% isn't optional data; it's a column in witness protection, and &lt;code&gt;ALTER TABLE ... GENERATED ALWAYS AS&lt;/code&gt; will let it out. A path under 1% is usually somebody's abandoned experiment.&lt;/p&gt;




&lt;h2&gt;
  
  
  The short version
&lt;/h2&gt;

&lt;p&gt;Your agent isn't lying on purpose. It's answering from an empty catalog, and &lt;code&gt;jsonb&lt;/code&gt; fails silently, so nothing in the loop can distinguish a wrong query from no data. Fix the catalog problem and the lying stops:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Walk the JSON server-side.&lt;/strong&gt; Recursive CTE over &lt;code&gt;jsonb_each&lt;/code&gt; / &lt;code&gt;jsonb_array_elements&lt;/code&gt;. Gigabytes stay where they are; kilobytes come back.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sample by page.&lt;/strong&gt; &lt;code&gt;TABLESAMPLE SYSTEM&lt;/code&gt; makes cost a function of the sample, not the table.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Converge, and admit what you missed.&lt;/strong&gt; Watch new-path discovery, estimate missing mass, never claim completeness you didn't verify.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The same argument holds with no AI anywhere in the picture, incidentally. This is just what &lt;code&gt;\d+&lt;/code&gt; for &lt;code&gt;jsonb&lt;/code&gt; would look like if Postgres shipped one.&lt;/p&gt;




&lt;p&gt;&lt;strong&gt;Want the full treatment?&lt;/strong&gt; I wrote a considerably longer version that goes into the storage layer, why the query planner is equally blind (&lt;code&gt;contsel&lt;/code&gt; returns a hardcoded 0.001 for &lt;em&gt;every&lt;/em&gt; containment query, regardless of your data), the four non-obvious details hiding in that recursive CTE, why &lt;code&gt;ctid&lt;/code&gt; isn't unique across partitions and will silently corrupt any dedupe built on it, and the statistics behind convergence:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;a href="https://dev.to/rasikraj01/the-column-no-llm-could-see-m86"&gt;The Column No LLM Could See&lt;/a&gt;&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Implementation and MCP wiring: &lt;a href="https://github.com/rasikraj01/psql-json-profiling-mcp" rel="noopener noreferrer"&gt;github.com/rasikraj01/psql-json-profiling-mcp&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;Either way: go find out what's actually in that column. I promise it's weirder than you think.&lt;/p&gt;

</description>
      <category>postgres</category>
      <category>ai</category>
      <category>mcp</category>
      <category>database</category>
    </item>
    <item>
      <title>The Column No LLM Could See</title>
      <dc:creator>Rasik</dc:creator>
      <pubDate>Sat, 25 Jul 2026 20:22:56 +0000</pubDate>
      <link>https://dev.to/rasikraj01/the-column-no-llm-could-see-m86</link>
      <guid>https://dev.to/rasikraj01/the-column-no-llm-could-see-m86</guid>
      <description>&lt;h2&gt;
  
  
  1. The number that was wrong
&lt;/h2&gt;

&lt;p&gt;It started with a question I didn't think was hard.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Me:&lt;/strong&gt; How many orders use express delivery?&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent:&lt;/strong&gt; &lt;em&gt;(runs `SELECT count(&lt;/em&gt;) FROM orders WHERE details -&amp;gt;&amp;gt; 'express_delivery' = 'true'`)*&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Agent:&lt;/strong&gt; None. No orders in the table use express delivery.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;I would have believed it. That's the part I keep coming back to. I had no particular reason to doubt it, the query looked like a query I'd have written, and "we launched express delivery and nobody's using it" is an entirely plausible fact about a business.&lt;/p&gt;

&lt;p&gt;I only checked because I happened to be looking at a support ticket from someone using express delivery.&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="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt; &lt;span class="o"&gt;#&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'{fulfillment,shippingV2,expressDelivery}'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- 2317&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Two thousand three hundred and seventeen. So the agent hadn't hallucinated — it ran a real query against a real database and faithfully reported the real result. The query was just wrong, and &lt;em&gt;nothing in the entire stack had been capable of noticing.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;That's the thread I pulled on. This is where it went.&lt;/p&gt;




&lt;h2&gt;
  
  
  2. Nobody knows what's in there
&lt;/h2&gt;

&lt;p&gt;First question: how was the agent supposed to know the right path? So I asked the database the same thing the agent had.&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="k"&gt;column_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;data_type&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;information_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;columns&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="k"&gt;table_name&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'orders'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; column_name | data_type
-------------+-----------
 id          | uuid
 details     | jsonb
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;jsonb&lt;/code&gt;. That's it. That's the entire contract.&lt;/p&gt;

&lt;p&gt;That column could hold &lt;code&gt;{}&lt;/code&gt; in every row, or a nine-level fulfillment tree with 400 distinct paths, and the catalog genuinely cannot tell you which — because as far as the Postgres type system is concerned, &lt;code&gt;jsonb&lt;/code&gt; is a single scalar type. The structure lives in the &lt;em&gt;value&lt;/em&gt;, not the &lt;em&gt;type&lt;/em&gt;. Nobody ever told the catalog anything, because there was never a place to write it down.&lt;/p&gt;

&lt;p&gt;So the agent did the only thing available to it: pattern-matched a plausible key name out of the column name and the surrounding conversation. It guessed &lt;code&gt;express_delivery&lt;/code&gt;. The data said &lt;code&gt;fulfillment.shippingV2.expressDelivery&lt;/code&gt;. Snake case or camel case, flat or nested, singular or plural — three independent coin flips, and it needed all three.&lt;/p&gt;

&lt;p&gt;But losing the coin flips isn't the interesting part. This 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="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;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'express_delivery'&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'true'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- 0 rows&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Not an error. Not a type mismatch. &lt;strong&gt;Zero rows.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;code&gt;-&amp;gt;&lt;/code&gt; on a missing key returns &lt;code&gt;NULL&lt;/code&gt;. &lt;code&gt;NULL = 'true'&lt;/code&gt; evaluates to &lt;code&gt;NULL&lt;/code&gt;. &lt;code&gt;NULL&lt;/code&gt; filters the row out. So every wrong guess produces a result set that is &lt;em&gt;pixel-identical&lt;/em&gt; to a correct query that found nothing.&lt;/p&gt;

&lt;p&gt;I sat with that for a while, because it reframes the whole problem. The agent wasn't lying. It had no feedback loop. A wrong query and a correct-but-empty query are indistinguishable from the inside, so there was no error to react to, no signal to self-correct on. It reported its guess as a finding with the serene confidence of a weather presenter, and it would have kept doing that forever.&lt;/p&gt;

&lt;p&gt;And — uncomfortably — so would I have. I make the same class of guess. I just have Slack history and a colleague named Dave to compensate, and the agent has neither. (Dave, in the finest tradition of the field, has since left the company.)&lt;/p&gt;

&lt;h3&gt;
  
  
  2.1 The planner can't see it either
&lt;/h3&gt;

&lt;p&gt;Somewhere in here I got curious about whether &lt;em&gt;anything&lt;/em&gt; in the system knew the shape of that column. Postgres runs &lt;code&gt;ANALYZE&lt;/code&gt;, so surely it collected something?&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;attname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;n_distinct&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;most_common_vals&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;has_mcv&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_stats&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;tablename&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'orders'&lt;/span&gt; &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="n"&gt;attname&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'details'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt; attname | n_distinct | has_mcv
---------+------------+---------
 details |      -0.91 | t
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Statistics! Except — look at what they're statistics &lt;em&gt;of&lt;/em&gt;. Postgres computed those over &lt;strong&gt;whole &lt;code&gt;jsonb&lt;/code&gt; blobs&lt;/strong&gt;, because &lt;code&gt;jsonb&lt;/code&gt; happens to have an equality operator and a sort order, so &lt;code&gt;ANALYZE&lt;/code&gt; dutifully answered the question "how often does this exact 40KB document appear," which is a question no human being has ever asked. Per-path statistics: none. Nothing anywhere knew that &lt;code&gt;status&lt;/code&gt; is &lt;code&gt;"delivered"&lt;/code&gt; in 91% of rows and &lt;code&gt;"refunded"&lt;/code&gt; in 2%.&lt;/p&gt;

&lt;p&gt;Which explains something I'd been vaguely annoyed by for months:&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;EXPLAIN&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;orders&lt;/span&gt; &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt; &lt;span class="o"&gt;@&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'{"status":"refunded"}'&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;





&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt; &lt;span class="n"&gt;Seq&lt;/span&gt; &lt;span class="n"&gt;Scan&lt;/span&gt; &lt;span class="k"&gt;on&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt;  &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;cost&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt;&lt;span class="p"&gt;..&lt;/span&gt;&lt;span class="mi"&gt;6120&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="mi"&gt;00&lt;/span&gt; &lt;span class="k"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;620&lt;/span&gt; &lt;span class="n"&gt;width&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;744&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
   &lt;span class="n"&gt;Filter&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt; &lt;span class="o"&gt;@&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'{"status": "refunded"}'&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="n"&gt;jsonb&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;620 rows out of 620,000. Exactly 0.1%. And I do mean &lt;em&gt;exactly&lt;/em&gt; — that's not an estimate, it's a constant. The selectivity function behind &lt;code&gt;@&amp;gt;&lt;/code&gt; is &lt;code&gt;contsel&lt;/code&gt;, and &lt;code&gt;contsel&lt;/code&gt; returns &lt;code&gt;DEFAULT_CONTAIN_SEL = 0.001&lt;/code&gt; unconditionally, every single time. It's right there in &lt;a href="https://github.com/postgres/postgres/blob/master/src/backend/utils/adt/selfuncs.c" rel="noopener noreferrer"&gt;&lt;code&gt;selfuncs.c&lt;/code&gt;&lt;/a&gt;, no data-dependent branch anywhere in it.&lt;/p&gt;

&lt;p&gt;So the planner gives the same answer for &lt;code&gt;status = 'delivered'&lt;/code&gt; at 91% of rows as it does for a key appearing twice in the whole table. Feed that fiction into a join and it picks a nested loop where it needed a hash join, and your p99 goes somewhere you'll be writing an incident report about.&lt;/p&gt;

&lt;p&gt;At this point the shape of the problem had changed for me. This wasn't an agent problem. Three separate consumers of that column — me, the query planner, and the LLM — were all flying blind, for exactly the same reason, and none of us had noticed because none of us had anything to be wrong &lt;em&gt;against&lt;/em&gt;. Nobody wrote the schema down, so everybody guesses. We'd built a small, quiet consensus of ignorance and been running it in production for two years.&lt;/p&gt;

&lt;p&gt;The obvious move was to write the schema down. Sample some rows, walk the JSON, emit a map of every key path with its types and how often each one shows up.&lt;/p&gt;

&lt;p&gt;That operation needs a name, and the one I've settled on is &lt;strong&gt;JSON profiling&lt;/strong&gt; — schema inference over a schema-on-read column. Basically the &lt;code&gt;\d+&lt;/code&gt; that Postgres would ship for &lt;code&gt;jsonb&lt;/code&gt; if &lt;code&gt;jsonb&lt;/code&gt; had feelings.&lt;/p&gt;

&lt;p&gt;It sounds like an afternoon. Reader, it was not an afternoon.&lt;/p&gt;




&lt;h2&gt;
  
  
  3. Afternoon one, in which I nearly take out staging
&lt;/h2&gt;

&lt;p&gt;The first version was twenty minutes of work, which should have been my first clue. Nothing that matters is twenty minutes of work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt; &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ORDER&lt;/span&gt; &lt;span class="k"&gt;BY&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt; &lt;span class="k"&gt;LIMIT&lt;/span&gt; &lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Unmarshal in Go, recurse over &lt;code&gt;map[string]any&lt;/code&gt;, accumulate paths into a set. I wrote it, pointed it at a staging replica of the 52-million-row table, and went to make coffee.&lt;/p&gt;

&lt;p&gt;It was still running when I got back. That was the first hint that I had not thought about this correctly. Three distinct things were wrong, and any one of them alone would have been disqualifying.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;&lt;code&gt;ORDER BY random()&lt;/code&gt; is a full scan wearing a trench coat.&lt;/strong&gt; There is no index on Earth that satisfies a random ordering. Postgres reads every row, assigns a random number to each, sorts all 52 million of them, hands you the top 1,000, and throws away 99.998% of the work it just did. I asked for a sample and paid for a census.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;I was detoasting the entire column for fun.&lt;/strong&gt; &lt;code&gt;jsonb&lt;/code&gt; values above roughly 2KB get pushed out-of-line into the TOAST relation, compressed — pglz by default, lz4 if you've set &lt;code&gt;default_toast_compression&lt;/code&gt; on PG14+. Reading the column means fetching every chunk from the TOAST table and decompressing it. So selecting &lt;code&gt;details&lt;/code&gt; for 1,000 rows of 40KB payloads is ~40MB of decompression, then ~40MB across the wire, then ~40MB of &lt;code&gt;encoding/json&lt;/code&gt; allocations in my process — to produce a few kilobytes of path names.&lt;/p&gt;

&lt;p&gt;That ratio is what actually stopped me. I was moving the data to the code, and &lt;strong&gt;the data was four orders of magnitude bigger than the answer.&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;And the walk itself was the slowest possible walk.&lt;/strong&gt; Go's &lt;code&gt;encoding/json&lt;/code&gt; into &lt;code&gt;map[string]any&lt;/code&gt; allocates an &lt;code&gt;interface{}&lt;/code&gt; per node and a &lt;code&gt;map&lt;/code&gt; per object. Pointed at deeply nested order documents, the unmarshalling dominated everything else and pushed the process into GC pressure it had no business being in.&lt;/p&gt;

&lt;p&gt;Three problems, one root cause, and once I saw it I couldn't unsee it: &lt;strong&gt;the walk was happening in the wrong place.&lt;/strong&gt;&lt;/p&gt;




&lt;h2&gt;
  
  
  4. Moving the walk into the database
&lt;/h2&gt;

&lt;p&gt;Here's the thing I'd been forgetting about &lt;code&gt;jsonb&lt;/code&gt;: it is not text.&lt;/p&gt;

&lt;p&gt;It's a parsed binary structure — a header, an array of &lt;code&gt;JEntry&lt;/code&gt; offset/type words, then the values, with object keys pre-sorted by length then bytewise so that key lookup is a binary search. It's all laid out in &lt;a href="https://github.com/postgres/postgres/blob/master/src/include/utils/jsonb.h" rel="noopener noreferrer"&gt;&lt;code&gt;jsonb.h&lt;/code&gt;&lt;/a&gt; if you enjoy reading header files, and I do. The operative consequence is that Postgres can iterate that structure without parsing anything, and it exposes exactly the three primitives a tree walk needs:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Function&lt;/th&gt;
&lt;th&gt;Yields&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jsonb_each(jsonb)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;(key text, value jsonb)&lt;/code&gt; for each object member&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jsonb_array_elements(jsonb)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;value jsonb&lt;/code&gt; per array element&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;jsonb_typeof(jsonb)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;object&lt;/code&gt;, &lt;code&gt;array&lt;/code&gt;, &lt;code&gt;string&lt;/code&gt;, &lt;code&gt;number&lt;/code&gt;, &lt;code&gt;boolean&lt;/code&gt;, &lt;code&gt;null&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;Which means the whole traversal can be a &lt;code&gt;WITH RECURSIVE&lt;/code&gt; CTE, and the only thing that ever crosses the wire is a &lt;code&gt;GROUP BY&lt;/code&gt; aggregate. One row per &lt;em&gt;distinct path&lt;/em&gt; — not one row per document.&lt;/p&gt;

&lt;p&gt;If there's one idea in this entire post, it's that one. Cost stops being a function of payload size (gigabytes) and becomes a function of distinct path count (hundreds). Everything after this section is refinement.&lt;/p&gt;

&lt;p&gt;So I wrote it:&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;WITH&lt;/span&gt; &lt;span class="k"&gt;RECURSIVE&lt;/span&gt; &lt;span class="n"&gt;walk&lt;/span&gt; &lt;span class="k"&gt;AS&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;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sample&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;LATERAL&lt;/span&gt; &lt;span class="n"&gt;jsonb_each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;jsonb_typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'object'&lt;/span&gt;

    &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;

    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&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;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+&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;walk&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;LATERAL&lt;/span&gt; &lt;span class="n"&gt;jsonb_each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;jsonb_typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'object'&lt;/span&gt;

    &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;

    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="s1"&gt;'[]'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+&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;walk&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;LATERAL&lt;/span&gt; &lt;span class="n"&gt;jsonb_array_elements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;el&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;jsonb_typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'array'&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;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;array_agg&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;jsonb_typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt; &lt;span class="k"&gt;count&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;walk&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;path&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And Postgres said no.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;ERROR:  recursive reference to query "walk" must not appear more than once
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h3&gt;
  
  
  4.1 One self-reference
&lt;/h3&gt;

&lt;p&gt;This is a real constraint, not a Postgres quirk — the SQL standard permits exactly &lt;strong&gt;one&lt;/strong&gt; reference to the recursive CTE inside its own recursive term. And I needed two, because descending into an object and descending into an array are two different rules.&lt;/p&gt;

&lt;p&gt;I spent an evening arguing with that error message before the shape landed. You don't need two branches that each reference &lt;code&gt;walk&lt;/code&gt;. You need &lt;em&gt;one&lt;/em&gt; branch that references &lt;code&gt;walk&lt;/code&gt;, joined against a subquery that produces children by either rule:&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;WITH&lt;/span&gt; &lt;span class="k"&gt;RECURSIVE&lt;/span&gt; &lt;span class="n"&gt;walk&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="c1"&gt;-- Seed: top-level object keys&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;depth&lt;/span&gt;
    &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;sample&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;LATERAL&lt;/span&gt; &lt;span class="n"&gt;jsonb_each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;jsonb_typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'object'&lt;/span&gt;

    &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;

    &lt;span class="c1"&gt;-- Seed: top-level array elements&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s1"&gt;'[]'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&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;sample&lt;/span&gt; &lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="k"&gt;LATERAL&lt;/span&gt; &lt;span class="n"&gt;jsonb_array_elements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;el&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;jsonb_typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'array'&lt;/span&gt;

    &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;

    &lt;span class="c1"&gt;-- Recursive term: exactly one reference to walk&lt;/span&gt;
    &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;rid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;path&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;+&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;walk&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;
    &lt;span class="k"&gt;CROSS&lt;/span&gt; &lt;span class="k"&gt;JOIN&lt;/span&gt; &lt;span class="k"&gt;LATERAL&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;
        &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'.'&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;key&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;suffix&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;
        &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;jsonb_each&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;
        &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;jsonb_typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'object'&lt;/span&gt;
      &lt;span class="k"&gt;UNION&lt;/span&gt; &lt;span class="k"&gt;ALL&lt;/span&gt;
        &lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="s1"&gt;'[]'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;el&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;
        &lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;jsonb_array_elements&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;el&lt;/span&gt;
        &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;jsonb_typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="s1"&gt;'array'&lt;/span&gt;
    &lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;child&lt;/span&gt;
    &lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;depth&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;&lt;/span&gt; &lt;span class="mi"&gt;8&lt;/span&gt;
&lt;span class="p"&gt;)&lt;/span&gt;
&lt;span class="k"&gt;SELECT&lt;/span&gt;
    &lt;span class="n"&gt;path&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="n"&gt;array_agg&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;jsonb_typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&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;types&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="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="n"&gt;rid&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;occurrences&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;array_agg&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt; &lt;span class="k"&gt;left&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
        &lt;span class="n"&gt;FILTER&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;jsonb_typeof&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;IN&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;'object'&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s1"&gt;'array'&lt;/span&gt;&lt;span class="p"&gt;)))[&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;3&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;examples&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;walk&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;path&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;CROSS JOIN LATERAL&lt;/code&gt; unions both descent rules into a single relation, so &lt;code&gt;walk&lt;/code&gt; appears once and both paths still fire. That trick is what makes the entire approach viable, and I'd love to tell you I derived it from the standard.&lt;/p&gt;

&lt;h3&gt;
  
  
  4.2 Three more things I got wrong first
&lt;/h3&gt;

&lt;p&gt;The query compiled. It also gave me wrong answers three times before it gave me right ones.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Occurrence counts were absurd.&lt;/strong&gt; &lt;code&gt;lineItems[].sku&lt;/code&gt; was showing up in 12,000 "documents" out of a 1,000-row sample. Of course it was — a path inside an array produces one walk row &lt;em&gt;per element&lt;/em&gt;, and an order with 12 line items yields 12 rows. Frequency only means anything per-document, so: &lt;code&gt;count(DISTINCT rid)&lt;/code&gt;, never &lt;code&gt;count(*)&lt;/code&gt;. Obvious in retrospect. It always is.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Path cardinality exploded.&lt;/strong&gt; My first version kept array indices, so &lt;code&gt;lineItems[0].sku&lt;/code&gt; and &lt;code&gt;lineItems[47].sku&lt;/code&gt; were different paths and a table with big arrays produced thousands of useless distinct entries. Collapsing every index to &lt;code&gt;[]&lt;/code&gt; fixes it and has a nice second property: &lt;code&gt;[]&lt;/code&gt; is the notation that maps straight back onto SQL, via &lt;code&gt;jsonb_array_elements&lt;/code&gt; or &lt;code&gt;details @? '$.lineItems[*].sku'&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;One document ate all the memory.&lt;/strong&gt; No depth limit, one pathological deeply-self-nesting blob, and the CTE ran until it hit the memory limit and took the connection with it. &lt;code&gt;depth &amp;lt; 8&lt;/code&gt; is a circuit breaker, and the same reasoning applies to array fan-out per node and to total distinct paths per column. Any unbounded recursion over user-supplied data is a loaded gun.&lt;/p&gt;

&lt;p&gt;With those fixed, this came back:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;                  path                   |   types   | occurrences |   examples
-----------------------------------------+-----------+-------------+---------------
 fulfillment.shippingV2.enabled          | {boolean} |         973 | {true}
 fulfillment.shippingV2.expressDelivery  | {boolean} |           3 | {true}
 lineItems[].sku                         | {string}  |         998 | {"SKU-2481"}
 status                                  | {string}  |        1000 | {"delivered"}
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Four lines. A few hundred bytes. And the mystery from §1 solves itself on sight — there's the path, spelled exactly the way the agent didn't guess it.&lt;/p&gt;

&lt;p&gt;That was the good moment. It lasted until I timed the thing.&lt;/p&gt;




&lt;h2&gt;
  
  
  5. Still reading 52 million rows
&lt;/h2&gt;

&lt;p&gt;Moving the walk server-side had fixed the &lt;em&gt;transfer&lt;/em&gt; problem completely. It had done precisely nothing about &lt;code&gt;ORDER BY random()&lt;/code&gt; still touching every row in the table.&lt;/p&gt;

&lt;p&gt;Which is where I found &lt;code&gt;TABLESAMPLE&lt;/code&gt;, sitting in the SQL:2003 standard and in Postgres since 9.5, waiting for somebody to notice it.&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Method&lt;/th&gt;
&lt;th&gt;Granularity&lt;/th&gt;
&lt;th&gt;Cost&lt;/th&gt;
&lt;th&gt;Notes&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TABLESAMPLE BERNOULLI (p)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Row&lt;/td&gt;
&lt;td&gt;Full scan&lt;/td&gt;
&lt;td&gt;Statistically pristine, no cheaper than a seq scan&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TABLESAMPLE SYSTEM (p)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;strong&gt;Page&lt;/strong&gt;&lt;/td&gt;
&lt;td&gt;~&lt;code&gt;p%&lt;/code&gt; of pages&lt;/td&gt;
&lt;td&gt;Random block reads. Cheap. Clustered.&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;TABLESAMPLE SYSTEM_ROWS (n)&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Page, exact &lt;code&gt;n&lt;/code&gt; rows&lt;/td&gt;
&lt;td&gt;~&lt;code&gt;n/rows_per_page&lt;/code&gt; pages&lt;/td&gt;
&lt;td&gt;contrib &lt;code&gt;tsm_system_rows&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;ORDER BY random()&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;Row&lt;/td&gt;
&lt;td&gt;Full scan + sort&lt;/td&gt;
&lt;td&gt;The trench coat&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;code&gt;SYSTEM&lt;/code&gt; is the one that changes the economics: it picks &lt;em&gt;pages&lt;/em&gt; at random and returns every row on each chosen page, so read cost scales with the sample rather than the table.&lt;/p&gt;

&lt;h3&gt;
  
  
  5.1 The thing that nearly stopped me
&lt;/h3&gt;

&lt;p&gt;I almost didn't use it, because &lt;strong&gt;page-level sampling is clustered sampling&lt;/strong&gt; and I could hear a statistics lecturer in my head. Rows sharing a page are physically adjacent, which means they correlate — same insert batch, same merchant, same time window. For estimating a &lt;em&gt;mean&lt;/em&gt;, that inflates variance badly and the lecturer would be right to glare.&lt;/p&gt;

&lt;p&gt;What unstuck me was noticing I wasn't estimating a mean. I was &lt;strong&gt;discovering the set of distinct key paths&lt;/strong&gt;, which is a coverage problem, and 500 rows drawn from 25 scattered pages cover the schema space nearly as well as 500 independent rows would. The clustering costs almost nothing when the question is "does this path exist anywhere."&lt;/p&gt;

&lt;p&gt;That distinction is worth internalizing generally: &lt;code&gt;SYSTEM&lt;/code&gt; is right for this question and wrong for plenty of others. Know which one you're asking before you reach for it.&lt;/p&gt;

&lt;p&gt;If &lt;code&gt;tsm_system_rows&lt;/code&gt; happens to be installed, it's strictly better — it takes a row count directly and reads pages until satisfied, so batch sizes are exact rather than approximate:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="n"&gt;EXTENSION&lt;/span&gt; &lt;span class="n"&gt;IF&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;EXISTS&lt;/span&gt; &lt;span class="n"&gt;tsm_system_rows&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="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="n"&gt;TABLESAMPLE&lt;/span&gt; &lt;span class="n"&gt;system_rows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;500&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;One rule I set immediately: the profiler &lt;em&gt;uses&lt;/em&gt; that extension if present and never, ever installs it. &lt;code&gt;CREATE EXTENSION&lt;/code&gt; wants superuser, and a read-only diagnostic tool has no business acquiring superuser. If you catch yourself typing that, go touch grass.&lt;/p&gt;

&lt;h3&gt;
  
  
  5.2 Choosing at runtime
&lt;/h3&gt;

&lt;p&gt;Which method to use depends on table size, and happily the planner already estimated that for free:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight sql"&gt;&lt;code&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt; &lt;span class="n"&gt;reltuples&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;bigint&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="n"&gt;est&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;relkind&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;text&lt;/span&gt;
&lt;span class="k"&gt;FROM&lt;/span&gt; &lt;span class="n"&gt;pg_class&lt;/span&gt;
&lt;span class="k"&gt;WHERE&lt;/span&gt; &lt;span class="n"&gt;oid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quote_ident&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="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;quote_ident&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;regclass&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Small trap in there, with a version number attached: &lt;code&gt;reltuples&lt;/code&gt; is &lt;code&gt;-1&lt;/code&gt; when the relation has never been analyzed on PG14+. Before that it was &lt;code&gt;0&lt;/code&gt;, which is maddeningly ambiguous with "the table is genuinely empty." So &lt;code&gt;-1&lt;/code&gt; has to mean &lt;em&gt;unknown&lt;/em&gt;, which is a different situation from &lt;em&gt;small&lt;/em&gt;, and routes differently:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;chooseSamplingMethod&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Querier&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fullScan&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;estTotal&lt;/span&gt; &lt;span class="kt"&gt;int64&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;canSample&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;fullScan&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="o"&gt;!&lt;/span&gt;&lt;span class="n"&gt;canSample&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;estTotal&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;estTotal&lt;/span&gt; &lt;span class="o"&gt;&amp;lt;=&lt;/span&gt; &lt;span class="n"&gt;fullScanThreshold&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"full_scan"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;db&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CheckExtension&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s"&gt;"tsm_system_rows"&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="o"&gt;&amp;amp;&amp;amp;&lt;/span&gt; &lt;span class="n"&gt;info&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Installed&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"system_rows"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;estTotal&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"system_pct"&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="s"&gt;"random"&lt;/span&gt; &lt;span class="c"&gt;// never analyzed: no basis for a percentage&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Below roughly a thousand rows there's no point sampling at all — scanning everything is cheaper than the machinery and gives an exact answer instead of an estimate. Just say which one you did in the output.&lt;/p&gt;

&lt;h3&gt;
  
  
  5.3 The partition footgun
&lt;/h3&gt;

&lt;p&gt;Then I pointed it at a partitioned table and got results that were quietly, subtly wrong.&lt;/p&gt;

&lt;p&gt;&lt;code&gt;TABLESAMPLE&lt;/code&gt; needs a physical relation. Ordinary tables (&lt;code&gt;relkind='r'&lt;/code&gt;) and materialized views (&lt;code&gt;'m'&lt;/code&gt;) are fine. Views and foreign tables have no &lt;code&gt;ctid&lt;/code&gt; and get rejected outright, loudly, which is the good kind of failure.&lt;/p&gt;

&lt;p&gt;Partitioned tables (&lt;code&gt;'p'&lt;/code&gt;) are the bad kind. The parent has no storage of its own — and, the part that got me, &lt;strong&gt;&lt;code&gt;ctid&lt;/code&gt; is not unique across partitions&lt;/strong&gt;. I was using &lt;code&gt;ctid&lt;/code&gt; to track which rows I'd already sampled, so two rows in different partitions sharing a &lt;code&gt;ctid&lt;/code&gt; meant my exclusion list was silently dropping rows I had never actually seen. No error. Just fewer rows than I thought, and no way to tell.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;sampleable&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="kt"&gt;string&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;bool&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"r"&lt;/span&gt; &lt;span class="o"&gt;||&lt;/span&gt; &lt;span class="n"&gt;relkind&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="s"&gt;"m"&lt;/span&gt;
&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Detect it up front, fall back to a synthetic &lt;code&gt;row_number()&lt;/code&gt; row id with a full scan, move on.&lt;/p&gt;

&lt;h3&gt;
  
  
  5.4 Batches that re-read each other
&lt;/h3&gt;

&lt;p&gt;One more, from the same family. Independent batches are independent draws, which means batch three is perfectly entitled to re-read batch one's pages. On a small table that's not a hypothetical — it's the default outcome.&lt;/p&gt;

&lt;p&gt;It corrupts two things at once. Your &lt;code&gt;rows_scanned&lt;/code&gt; count, obviously. But worse, and I didn't spot this for a while: a batch full of already-seen rows discovers zero new paths, which looks &lt;em&gt;exactly&lt;/em&gt; like the convergence signal I was about to build the entire stopping rule on. My tooling was about to lie to me in the same way the agent had. There's a lesson in there somewhere.&lt;/p&gt;

&lt;p&gt;Carry the sampled row ids forward and exclude them:&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;WHERE&lt;/span&gt; &lt;span class="n"&gt;details&lt;/span&gt; &lt;span class="k"&gt;IS&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;NULL&lt;/span&gt;
  &lt;span class="k"&gt;AND&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctid&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="k"&gt;ANY&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="nb"&gt;text&lt;/span&gt;&lt;span class="p"&gt;[]::&lt;/span&gt;&lt;span class="n"&gt;tid&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;text[] → tid[]&lt;/code&gt; double cast looks silly and is, in practice, how you round-trip &lt;code&gt;ctid&lt;/code&gt; values through a driver with no native &lt;code&gt;tid&lt;/code&gt; array type. Sometimes the pragmatic answer is the ugly one.&lt;/p&gt;




&lt;h2&gt;
  
  
  6. How many rows is enough?
&lt;/h2&gt;

&lt;p&gt;Sampling worked. Which immediately raised the question I'd been avoiding: how many rows should it read?&lt;/p&gt;

&lt;p&gt;Any fixed number is wrong in both directions simultaneously. 5,000 rows is wasteful on a table with 12 paths and insufficient on a table with 800. So don't fix the number — watch the signal. Profile in batches, count newly-discovered paths per batch, stop after N consecutive batches turn up nothing new.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;zeroStreak&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;QueryRows&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;q&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batchLimit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;seenRids&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;fmt&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Errorf&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"sample %s.%s: %w"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schemaName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tableName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Batches&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;

    &lt;span class="n"&gt;batchRows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;newPaths&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batchRids&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;mergeBatch&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;aggs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
    &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RowsScanned&lt;/span&gt; &lt;span class="o"&gt;+=&lt;/span&gt; &lt;span class="n"&gt;batchRows&lt;/span&gt;
    &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewPathsLastBatch&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;newPaths&lt;/span&gt;
    &lt;span class="n"&gt;seenRids&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="nb"&gt;append&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;seenRids&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;batchRids&lt;/span&gt;&lt;span class="o"&gt;...&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;

    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;newPaths&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;zeroStreak&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt; &lt;span class="k"&gt;else&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;zeroStreak&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;zeroStreak&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ConvergenceBatches&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Converged&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="no"&gt;true&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;report&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;RowsScanned&lt;/span&gt; &lt;span class="o"&gt;&amp;gt;=&lt;/span&gt; &lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;MaxRows&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;break&lt;/span&gt; &lt;span class="c"&gt;// budget hit before convergence&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;It converged much faster than I expected, and the reason is structural rather than lucky. Production JSON isn't arbitrary — it's emitted by application code, a serializer or a checkout handler or a webhook consumer, so the universe of possible paths is bounded by a finite amount of source, and the &lt;em&gt;common&lt;/em&gt; paths get written by every code path that touches the table. It's the &lt;a href="https://en.wikipedia.org/wiki/Coupon_collector%27s_problem" rel="noopener noreferrer"&gt;coupon collector's problem&lt;/a&gt; with a brutally non-uniform distribution: a few hundred rows catch everything above ~1% frequency, and the long tail is optional keys, deprecated fields, and the ghosts of feature flags past. Most tables I've pointed this at plateau by row 300–500.&lt;/p&gt;

&lt;h3&gt;
  
  
  6.1 The 1953 paper
&lt;/h3&gt;

&lt;p&gt;Fast convergence answered "when do I stop." It didn't answer the question that actually worried me, which was &lt;strong&gt;"what did I miss?"&lt;/strong&gt;&lt;/p&gt;

&lt;p&gt;Because sampling &lt;em&gt;will&lt;/em&gt; miss the key that appears in 3 rows out of 52 million. That's not a bug, that's the deal I signed when I stopped doing full scans. But shipping a tool that quietly pretends otherwise felt like recreating the original problem in a new location.&lt;/p&gt;

&lt;p&gt;It turns out there's a proper estimator for exactly this, it's called &lt;strong&gt;Good–Turing missing mass&lt;/strong&gt;, and it is older than every other piece of technology mentioned in this post combined. &lt;a href="https://www.jstor.org/stable/2333344" rel="noopener noreferrer"&gt;Good, 1953&lt;/a&gt; — the same I. J. Good who spent the war at Bletchley with Turing, now quietly solving my &lt;code&gt;jsonb&lt;/code&gt; problem from eight decades away. (Gale and Sampson later wrote the version you can actually read.)&lt;/p&gt;

&lt;p&gt;Let &lt;code&gt;f₁&lt;/code&gt; be the number of paths seen in &lt;em&gt;exactly one&lt;/em&gt; sampled document, and &lt;code&gt;N&lt;/code&gt; the number of documents sampled. Then:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;P(next document contains an unseen path) ≈ f₁ / N
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;That's the whole thing. And you already have per-path occurrence counts sitting in memory, so it costs a loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="c"&gt;// singletons: paths observed in exactly one sampled document.&lt;/span&gt;
&lt;span class="k"&gt;func&lt;/span&gt; &lt;span class="n"&gt;missingMass&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;paths&lt;/span&gt; &lt;span class="p"&gt;[]&lt;/span&gt;&lt;span class="n"&gt;PathInfo&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;rowsScanned&lt;/span&gt; &lt;span class="kt"&gt;int&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;rowsScanned&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="n"&gt;singletons&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="m"&gt;0&lt;/span&gt;
    &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;_&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="k"&gt;range&lt;/span&gt; &lt;span class="n"&gt;paths&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Occurrences&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="m"&gt;1&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="n"&gt;singletons&lt;/span&gt;&lt;span class="o"&gt;++&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="p"&gt;}&lt;/span&gt;
    &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;singletons&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="o"&gt;/&lt;/span&gt; &lt;span class="kt"&gt;float64&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rowsScanned&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;Reading it: &lt;code&gt;0.004&lt;/code&gt; means roughly one document in 250 carries a path you haven't seen, which is comfortably good enough to write queries against. &lt;code&gt;0.20&lt;/code&gt; means you sampled a heterogeneous table and your map isn't trustworthy yet, and you should either raise the budget or stop trusting the output. Of everything I bolted onto the naive version, this was the highest-value addition — it turns "did I sample enough?" from a vibe into a number.&lt;/p&gt;

&lt;p&gt;Which meant the output could finally be honest about itself:&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="nl"&gt;"sampling"&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;"method"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"system_rows"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"rows_scanned"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"estimated_total_rows"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;52000000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"batches"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"new_paths_last_batch"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"converged"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"missing_mass"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.004&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"note"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"frequency_pct is approximate (computed over the sample, not the whole table)"&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;span class="p"&gt;}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Every consumer — human or model — needs to know whether it converged, whether the budget cut it short, whether a cap fired. A profiler that reports "complete" after touching 0.003% of a table is &lt;em&gt;worse&lt;/em&gt; than no profiler at all, because it launders a known unknown into an unknown unknown. And when you genuinely need certainty, &lt;code&gt;full_scan&lt;/code&gt; is still there and you pay full price for it with your eyes open.&lt;/p&gt;




&lt;h2&gt;
  
  
  7. What the map turned out to be good for
&lt;/h2&gt;

&lt;p&gt;I'd built this to stop an agent from guessing. It immediately started earning its keep in ways I hadn't planned.&lt;/p&gt;

&lt;p&gt;Here's the output shape:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"schema"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"public"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"table"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"orders"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
  &lt;/span&gt;&lt;span class="nl"&gt;"columns"&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;"column"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"details"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"data_type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"jsonb"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
    &lt;/span&gt;&lt;span class="nl"&gt;"paths"&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="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"fulfillment.shippingV2.expressDelivery"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"types"&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="s2"&gt;"boolean"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"occurrences"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"frequency_pct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;0.3&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"examples"&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="s2"&gt;"true"&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="nl"&gt;"path"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"lineItems[].sku"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"types"&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="s2"&gt;"string"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"occurrences"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;998&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"frequency_pct"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mf"&gt;99.8&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
        &lt;/span&gt;&lt;span class="nl"&gt;"examples"&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="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;SKU-2481&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&gt;SKU-0977&lt;/span&gt;&lt;span class="se"&gt;\"&lt;/span&gt;&lt;span class="s2"&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="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;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;The path notation isn't decorative — it maps mechanically onto SQL, which is the whole reason for choosing it:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Path&lt;/th&gt;
&lt;th&gt;Query&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fulfillment.shippingV2.expressDelivery&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;details #&amp;gt;&amp;gt; '{fulfillment,shippingV2,expressDelivery}'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;fulfillment.shippingV2.expressDelivery&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;details @&amp;gt; '{"fulfillment":{"shippingV2":{"expressDelivery":true}}}'&lt;/code&gt; (GIN-indexable)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lineItems[].sku&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;&lt;code&gt;jsonb_array_elements(details-&amp;gt;'lineItems') -&amp;gt;&amp;gt; 'sku'&lt;/code&gt;&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lineItems[].sku&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;details @? '$.lineItems[*].sku'&lt;/code&gt; (PG12+)&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;&lt;code&gt;lineItems[].sku&lt;/code&gt;&lt;/td&gt;
&lt;td&gt;
&lt;code&gt;JSON_TABLE(...)&lt;/code&gt; (PG17+)&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;h3&gt;
  
  
  7.1 The bug I wasn't looking for
&lt;/h3&gt;

&lt;p&gt;The first real profile I ran on production came back with a path typed &lt;code&gt;["number", "string"]&lt;/code&gt;, and I had to read it twice.&lt;/p&gt;

&lt;p&gt;Both &lt;code&gt;{"amount": 1999}&lt;/code&gt; and &lt;code&gt;{"amount": "19.99"}&lt;/code&gt; were in that column. Two writers, two conventions — one storing integer cents, one storing dollars as a string. Every consumer downstream was carrying a latent cast error &lt;em&gt;and&lt;/em&gt; a latent 100x pricing bug, stacked on top of each other, and it had been there long enough that nobody could remember which one came first.&lt;/p&gt;

&lt;p&gt;I hadn't asked "is my data consistent." I got the answer anyway. Same signal shows up for &lt;code&gt;["boolean", "null"]&lt;/code&gt;, and for the path that's sometimes an object and sometimes an array — the timeless "we turned a scalar into a list and backfilled absolutely nothing" migration.&lt;/p&gt;

&lt;h3&gt;
  
  
  7.2 Frequency reads as a design review
&lt;/h3&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;~100%&lt;/strong&gt; — this was never optional data. It's a column that hasn't been promoted yet, and it knows it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;40–90%&lt;/strong&gt; — genuinely optional. This is &lt;code&gt;jsonb&lt;/code&gt; being used correctly. Leave it alone.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;&amp;lt;1%&lt;/strong&gt; — a dead field, someone's abandoned experiment, or a bug writing keys it shouldn't. Grep the codebase; nine times out of ten it's deletable.&lt;/li&gt;
&lt;/ul&gt;

&lt;h3&gt;
  
  
  7.3 Index selection stops being astrology
&lt;/h3&gt;

&lt;p&gt;Cardinality and access shape happen to be exactly the inputs the index decision always needed:&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;-- Broad @&amp;gt; / ? / @? over many unpredictable paths → jsonb_ops (default)&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;orders_details_gin&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- Only @&amp;gt; containment, want a smaller/faster index → jsonb_path_ops&lt;/span&gt;
&lt;span class="c1"&gt;-- Indexes hashes of full paths: ~half the size, but no ? / ?| / ?&amp;amp; support.&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;orders_details_gin&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;USING&lt;/span&gt; &lt;span class="n"&gt;gin&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt; &lt;span class="n"&gt;jsonb_path_ops&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;

&lt;span class="c1"&gt;-- One hot path, high selectivity → an expression B-tree beats GIN every time&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="n"&gt;orders_status&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;And that third one closes a loop from §2.1 in a way I found genuinely satisfying: &lt;strong&gt;&lt;code&gt;ANALYZE&lt;/code&gt; collects real statistics on expression indexes.&lt;/strong&gt; Create it, analyze, and &lt;code&gt;details-&amp;gt;&amp;gt;'status' = 'delivered'&lt;/code&gt; suddenly gets an MCV-backed estimate instead of &lt;code&gt;contsel&lt;/code&gt;'s eternal 0.001. The planner starts picking join strategies like it can see again — because now, for that one path, it can.&lt;/p&gt;

&lt;h3&gt;
  
  
  7.4 Promote, freeze, and test the shape
&lt;/h3&gt;

&lt;p&gt;A path sitting at 100% frequency with a stable type isn't schema-less data. It's a column in witness protection. On PG12+, generated columns extract it with zero application changes — typed, indexable, always consistent:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;COLUMN&lt;/span&gt; &lt;span class="n"&gt;status&lt;/span&gt; &lt;span class="nb"&gt;text&lt;/span&gt;
    &lt;span class="k"&gt;GENERATED&lt;/span&gt; &lt;span class="n"&gt;ALWAYS&lt;/span&gt; &lt;span class="k"&gt;AS&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt; &lt;span class="o"&gt;-&amp;gt;&amp;gt;&lt;/span&gt; &lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="n"&gt;STORED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="k"&gt;CREATE&lt;/span&gt; &lt;span class="k"&gt;INDEX&lt;/span&gt; &lt;span class="k"&gt;ON&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Profiling tells you what the shape &lt;em&gt;is&lt;/em&gt; today; a constraint keeps it that way tomorrow:&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;ALTER&lt;/span&gt; &lt;span class="k"&gt;TABLE&lt;/span&gt; &lt;span class="n"&gt;orders&lt;/span&gt; &lt;span class="k"&gt;ADD&lt;/span&gt; &lt;span class="k"&gt;CONSTRAINT&lt;/span&gt; &lt;span class="n"&gt;details_has_status&lt;/span&gt;
    &lt;span class="k"&gt;CHECK&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt; &lt;span class="o"&gt;?&lt;/span&gt; &lt;span class="s1"&gt;'status'&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;NOT&lt;/span&gt; &lt;span class="k"&gt;VALID&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;
&lt;span class="c1"&gt;-- NOT VALID: enforce on new rows now, backfill and VALIDATE CONSTRAINT later&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;For a real contract rather than a spot check, &lt;code&gt;pg_jsonschema&lt;/code&gt; puts full JSON Schema validation inside a &lt;code&gt;CHECK&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;And the profile itself is a snapshot of your implicit schema, which means it can be diffed. Store it, re-profile in CI, compare. A path that changed type, a required path slipping below 100%, a new path nobody mentioned in code review — all of it becomes a visible diff today instead of an incident three weeks from now. Cheap insurance. Take it.&lt;/p&gt;




&lt;h2&gt;
  
  
  8. Back to the agent
&lt;/h2&gt;

&lt;p&gt;Which brings this back around to the thing that started it.&lt;/p&gt;

&lt;p&gt;An LLM writing SQL against your database has strictly less context than your greenest new hire. No tribal knowledge, no Slack archive, no "just ask Dave" — Dave, as established, is gone. All it has is the catalog, and on &lt;code&gt;jsonb&lt;/code&gt; the catalog is an empty page. So it guesses, gets zero rows, and reports zero rows as a &lt;em&gt;finding&lt;/em&gt;, and the failure is invisible to the model and to whoever reads its output.&lt;/p&gt;

&lt;p&gt;Wiring the profiler in as an MCP tool closes that loop:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight go"&gt;&lt;code&gt;&lt;span class="n"&gt;s&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;AddTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
    &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewTool&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"profile_json_columns"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithDescription&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"Infer json/jsonb structure (key paths, types, frequency, examples); block-samples to convergence."&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"schema"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Required&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"table"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Required&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt;
        &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithString&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"column"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithNumber&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"max_rows"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
        &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;WithBoolean&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s"&gt;"full_scan"&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="p"&gt;),&lt;/span&gt;
    &lt;span class="k"&gt;func&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt; &lt;span class="n"&gt;context&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Context&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;req&lt;/span&gt; &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallToolRequest&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;CallToolResult&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="kt"&gt;error&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
        &lt;span class="n"&gt;prof&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;:=&lt;/span&gt; &lt;span class="n"&gt;jsonprofile&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ProfileJSONColumns&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ctx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;schemaName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tableName&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;opts&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;err&lt;/span&gt; &lt;span class="o"&gt;!=&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
            &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;mcp&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;NewToolResultError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;err&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;Error&lt;/span&gt;&lt;span class="p"&gt;()),&lt;/span&gt; &lt;span class="no"&gt;nil&lt;/span&gt;
        &lt;span class="p"&gt;}&lt;/span&gt;
        &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;jsonResult&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;prof&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;The model calls it, receives the real path map, and writes the correct expression on the first attempt instead of the fourth.&lt;/p&gt;

&lt;p&gt;The token math points the same direction, which I hadn't anticipated but should have. The alternative — dumping 20 raw documents into context and letting the model infer — costs tens of thousands of tokens to infer &lt;em&gt;badly&lt;/em&gt;: it sees whichever paths happened to occur in those particular 20 rows, with no frequency information and no concept of what it missed. Optional fields look mandatory, rare fields don't exist. A profile of the same table is a few hundred tokens, covers 1,500 rows, and arrives with explicit frequency and convergence metadata attached. The database does the aggregation; the model gets the conclusion. That's the correct division of labor, and it's the same division of labor that fixed the performance problem back in §4.&lt;/p&gt;

&lt;p&gt;Strip the AI out entirely and the argument survives, incidentally. This is just what &lt;code&gt;\d+&lt;/code&gt; for &lt;code&gt;jsonb&lt;/code&gt; would look like, if Postgres shipped one.&lt;/p&gt;




&lt;h2&gt;
  
  
  9. Notes from production, or: scars
&lt;/h2&gt;

&lt;p&gt;Things that bit me &lt;em&gt;after&lt;/em&gt; the part of the project where I told people it was finished.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Bound everything.&lt;/strong&gt; Max depth (8), array elements walked per node (50), distinct paths per column (2000), examples per path (3), example string length (80 chars). One pathological document should degrade your &lt;em&gt;output&lt;/em&gt;, never your &lt;em&gt;database&lt;/em&gt;. And when a cap fires, report &lt;code&gt;truncated: true&lt;/code&gt; — silent truncation is just lying with extra steps, and this entire post is about that.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Cast &lt;code&gt;json&lt;/code&gt; to &lt;code&gt;jsonb&lt;/code&gt; before walking.&lt;/strong&gt; The older &lt;code&gt;json&lt;/code&gt; type is stored as raw text and re-parsed on every function call, so walking it is dramatically slower — and it faithfully preserves duplicate keys and whitespace you never wanted. One &lt;code&gt;details::jsonb&lt;/code&gt; fixes both.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Read-only by default.&lt;/strong&gt; This runs against production, so act like it. &lt;code&gt;BEGIN READ ONLY&lt;/code&gt; plus a &lt;code&gt;statement_timeout&lt;/code&gt;: the tool physically cannot write, and cannot pin a snapshot forever when somebody inevitably points it at the 400M-row table.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Verify identifiers against the catalog, then quote them.&lt;/strong&gt; Schema and table names get interpolated into query text — you cannot parameterize an identifier, that's just SQL. So resolve them through &lt;code&gt;information_schema&lt;/code&gt; first, so only catalog-verified strings reach the builder, then &lt;code&gt;quote_ident&lt;/code&gt; on the way in anyway. Both. Not either.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Build the query once.&lt;/strong&gt; Query text is fully determined by schema, table, column set, and sampling method, none of which change between batches. Hoist it out of the loop and let the server reuse the plan.&lt;/p&gt;




&lt;h2&gt;
  
  
  10. What I'd tell you if we only had a minute
&lt;/h2&gt;

&lt;p&gt;&lt;code&gt;jsonb&lt;/code&gt; doesn't delete your schema. It stops &lt;em&gt;writing it down&lt;/em&gt;, and hands every future reader the job of reconstructing it from memory — and memory, as we've established, left the company and does not answer LinkedIn messages. JSON profiling writes it back down.&lt;/p&gt;

&lt;p&gt;Three ideas, and everything else in this post is detail:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Walk server-side.&lt;/strong&gt; A recursive CTE over &lt;code&gt;jsonb_each&lt;/code&gt; / &lt;code&gt;jsonb_array_elements&lt;/code&gt; keeps gigabytes of traversal where the data already lives and sends back kilobytes. One self-reference, so union your descent rules inside a &lt;code&gt;LATERAL&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sample by page.&lt;/strong&gt; &lt;code&gt;TABLESAMPLE SYSTEM&lt;/code&gt; / &lt;code&gt;system_rows&lt;/code&gt; makes read cost a function of the sample, not the table — and clustered sampling is fine when your question is coverage rather than estimation.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Stop on convergence, and report your uncertainty.&lt;/strong&gt; Watch new-path discovery, estimate missing mass with &lt;code&gt;f₁/N&lt;/code&gt;, and never claim a completeness you didn't verify.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;I eventually packaged all of it — profiler, sampling logic, convergence loop, tests, MCP wiring — into a small Go server so my agents would stop guessing at my columns: &lt;a href="https://github.com/rasikraj01/psql-json-profiling-mcp" rel="noopener noreferrer"&gt;github.com/rasikraj01/psql-json-profiling-mcp&lt;/a&gt;. If you'd rather build your own, everything you need is on this page.&lt;/p&gt;

&lt;p&gt;Either way: go find out what's actually in that column. Mine had a pricing bug in it. Yours is weirder than you think.&lt;/p&gt;




&lt;h2&gt;
  
  
  References
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/datatype-json.html" rel="noopener noreferrer"&gt;PostgreSQL: JSON Types&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/functions-json.html" rel="noopener noreferrer"&gt;PostgreSQL: JSON Functions and Operators&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/sql-select.html#SQL-FROM" rel="noopener noreferrer"&gt;PostgreSQL: &lt;code&gt;TABLESAMPLE&lt;/code&gt; in &lt;code&gt;SELECT&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/tsm-system-rows.html" rel="noopener noreferrer"&gt;PostgreSQL: &lt;code&gt;tsm_system_rows&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/queries-with.html" rel="noopener noreferrer"&gt;PostgreSQL: &lt;code&gt;WITH&lt;/code&gt; Queries (Recursive CTEs)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/datatype-json.html#JSON-INDEXING" rel="noopener noreferrer"&gt;PostgreSQL: GIN operator classes for &lt;code&gt;jsonb&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/storage-toast.html" rel="noopener noreferrer"&gt;PostgreSQL: TOAST&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/ddl-generated-columns.html" rel="noopener noreferrer"&gt;PostgreSQL: Generated Columns&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/row-estimation-examples.html" rel="noopener noreferrer"&gt;PostgreSQL: Row Estimation Examples&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/postgres/postgres/blob/master/src/backend/utils/adt/selfuncs.c" rel="noopener noreferrer"&gt;&lt;code&gt;selfuncs.c&lt;/code&gt; — &lt;code&gt;contsel&lt;/code&gt; / &lt;code&gt;DEFAULT_CONTAIN_SEL&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/postgres/postgres/blob/master/src/include/utils/jsonb.h" rel="noopener noreferrer"&gt;&lt;code&gt;jsonb.h&lt;/code&gt; — on-disk &lt;code&gt;jsonb&lt;/code&gt; representation&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/functions-json.html#FUNCTIONS-SQLJSON-PATH" rel="noopener noreferrer"&gt;SQL/JSON path language (PG12+)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.postgresql.org/docs/current/functions-json.html#FUNCTIONS-SQLJSON-TABLE" rel="noopener noreferrer"&gt;&lt;code&gt;JSON_TABLE&lt;/code&gt; (PG17+)&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://github.com/supabase/pg_jsonschema" rel="noopener noreferrer"&gt;&lt;code&gt;pg_jsonschema&lt;/code&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://www.jstor.org/stable/2333344" rel="noopener noreferrer"&gt;Good, I. J. (1953), &lt;em&gt;The population frequencies of species and the estimation of population parameters&lt;/em&gt;&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://en.wikipedia.org/wiki/Coupon_collector%27s_problem" rel="noopener noreferrer"&gt;Coupon collector's problem&lt;/a&gt;&lt;/li&gt;
&lt;li&gt;&lt;a href="https://modelcontextprotocol.io" rel="noopener noreferrer"&gt;Model Context Protocol&lt;/a&gt;&lt;/li&gt;
&lt;/ul&gt;

</description>
      <category>postgres</category>
      <category>database</category>
      <category>go</category>
      <category>ai</category>
    </item>
  </channel>
</rss>
