<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>EXPLAIN ANALYZE</title><link>https://explainanalyze.com/</link><description>Recent content on EXPLAIN ANALYZE</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><lastBuildDate>Thu, 04 Jun 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://explainanalyze.com/index.xml" rel="self" type="application/rss+xml"/><item><title>Death by a Thousand Cuts: the AI Database Failure You Can't Restore</title><link>https://explainanalyze.com/p/death-by-a-thousand-cuts-the-ai-database-failure-you-cant-restore/</link><pubDate>Thu, 04 Jun 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/death-by-a-thousand-cuts-the-ai-database-failure-you-cant-restore/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Death by a Thousand Cuts: the AI Database Failure You Can't Restore" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;The catastrophic AI failure (the agent that DROPs a table) is the recoverable one: loud, attributable, and on a gated pipeline it mostly can&amp;rsquo;t happen anyway. What actually bleeds you is the change that clears every gate because the gates check correctness at review time, and the failure is a function of volume and time, both of which are zero when the PR is open. Plot failures on loud-vs-quiet and recoverable-vs-not, and AI floods the quiet-and-unrecoverable corner the pipeline was never watching.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;A scraper ships behind the same pipeline as everything else: feature branch, two approvals, CI green, a day in staging, then deploy. There is already a &lt;code&gt;postings&lt;/code&gt; table, one row per job posting the crawler tracks, keyed by &lt;code&gt;posting_id&lt;/code&gt;. Part of the change is a new table to track crawl state, storing the content hash of every posting each run sees so the next run can tell what changed. The agent designed the table, a reviewer approved it, and at review time it held zero rows. It looked reasonable:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;crawl_state&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BIGSERIAL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;run_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;posting_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;postings&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;posting_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;content_hash&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;CHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- SHA-256 of the posting body
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;crawled_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DEFAULT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INDEX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;crawl_state&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;posting_id&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;It was reasonable, for about three months.&lt;/p&gt;
&lt;p&gt;Nothing on that screen looks wrong, and that is the problem. A surrogate &lt;code&gt;id&lt;/code&gt;, a foreign key to &lt;code&gt;postings&lt;/code&gt;, an index on the column you look postings up by. It reviews as boilerplate. The grain is the part nobody states out loud: one row per posting &lt;em&gt;per run&lt;/em&gt;. Every pass appends the entire posting set under a fresh &lt;code&gt;run_id&lt;/code&gt; instead of updating the rows already there, so the table grows by the full set every time the scraper runs. &lt;code&gt;postings&lt;/code&gt; holds roughly 100,000 rows. Three months and a hundred-odd runs later, &lt;code&gt;crawl_state&lt;/code&gt; holds 11 million. The job that decides whether a posting changed runs the obvious query:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;content_hash&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;crawl_state&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;posting_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;crawled_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LIMIT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The &lt;code&gt;posting_id&lt;/code&gt; index finds the matching rows, but there are now a hundred-odd of them per posting, and to return the single latest hash it sorts that pile by &lt;code&gt;crawled_at&lt;/code&gt; on every call. Across the working set the pipeline is timing out. Asked to fix the slowness, the agent recommends what it always recommends: widen the index to &lt;code&gt;CREATE INDEX ON crawl_state (posting_id, crawled_at DESC)&lt;/code&gt;, so the latest-hash lookup stops sorting.&lt;/p&gt;
&lt;p&gt;An engineer who knows the system reads it differently. The table shouldn&amp;rsquo;t have run grain at all. A posting&amp;rsquo;s current hash is one value, stored once and overwritten each run, and there is nothing to accumulate. Better than that, the hash isn&amp;rsquo;t a separate concern from the posting. It belongs on the &lt;code&gt;postings&lt;/code&gt; row that already exists, so there is no second table, no &lt;code&gt;run_id&lt;/code&gt;, and no latest-per-posting lookup at all:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;postings&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ADD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COLUMN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;content_hash&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;CHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;64&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ADD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COLUMN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;hash_checked_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- each run, per posting:
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;postings&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;content_hash&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&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="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;hash_checked_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;posting_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;$&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;content_hash&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&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="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;That stays at 100k rows forever, the changed-or-not check is a single primary-key row read, and there is no &lt;code&gt;crawl_state&lt;/code&gt; to bloat. The index the agent suggested speeds the symptom while doubling down on the grain that is the actual bug, paying write cost and storage on a table that should not exist. And there is no deploy to revert. The bad grain is a schema decision three months old plus 11 million rows of accumulated state, and unwinding it is a planned migration and a backfill, reviewed like any other change, not a rollback.&lt;/p&gt;
&lt;h2 id="the-drop-is-the-lucky-case-and-heres-the-2x2-that-shows-why"&gt;The DROP is the lucky case, and here&amp;rsquo;s the 2x2 that shows why
&lt;/h2&gt;&lt;p&gt;The failure everyone pictures is the destructive one: the &lt;code&gt;DROP TABLE&lt;/code&gt;, the migration that truncates the wrong relation, the script that deletes the binlogs. On a gated system that is the one you have mostly handled, with destructive migrations caught in review, credentials scoped, and a restore runbook practiced. When the Replit agent &lt;a class="link" href="https://fortune.com/2025/07/23/ai-coding-tool-replit-wiped-database-called-it-a-catastrophic-failure/" target="_blank" rel="noopener"
 &gt;wiped a production database during a code freeze in July 2025&lt;/a&gt;, the data was restored: loud, attributable to one timestamp, recoverable. The scraper cleared that same pipeline because at merge time it was correct, with no rows for the grain bug to express and a CI seed that never reached the row count where it breaks.&lt;/p&gt;
&lt;p&gt;Rank a failure on two axes, loud-vs-quiet and recoverable-vs-not, and you get four corners:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Loud and recoverable&lt;/strong&gt; is the DROP. You know instantly and you roll it back.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Loud and unrecoverable&lt;/strong&gt; is rarer: a destructive operation you catch but can&amp;rsquo;t undo.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Quiet and recoverable&lt;/strong&gt; is the bug that sat unnoticed but is still reversible when you find it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Quiet and unrecoverable&lt;/strong&gt; ruins quarters. You don&amp;rsquo;t find out for months, and by then the prior state is gone or never existed as a clean artifact.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The axes correlate, which is what makes the bad corner deep. Loud failures get caught while the prior state still exists; quiet ones sit, and the longer one sits the more downstream systems consume it as truth, until a recoverable error has been aggregated and propagated into an unrecoverable one. Loudness buys the time, so quiet and unrecoverable travel together.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;Note&lt;/strong&gt;
 &lt;div&gt;This post is about the write path: statements that change data, schema, or the planner&amp;rsquo;s behavior. The read-path failure (an AI-generated &lt;code&gt;SELECT&lt;/code&gt; that returns a confidently wrong number) is the sibling problem, covered in &lt;a class="link" href="https://explainanalyze.com/p/what-ai-gets-wrong-about-your-database/" target="_blank" rel="noopener"
 &gt;What AI Gets Wrong About Your Database&lt;/a&gt;. A wrong read misleads a decision; a wrong write becomes the new truth.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;AI floods the bad corner for a structural reason. Each change it ships is plausible: it compiles, runs, returns the right shape, passes whatever checks exist. That plausibility is the &lt;a class="link" href="https://explainanalyze.com/p/corruption-is-a-feature-not-a-bug-why-llms-corrupt-by-design/" &gt;corruption floor&lt;/a&gt;, the same mechanism that makes the output useful making it occasionally wrong in a way that looks exactly right. A loud failure is one the output failed to make plausible; the quiet one is the model working as designed. Then volume multiplies it: a team shipping eighty changes a week instead of eight samples that floor ten times as often, on the same review and CI budget. The DROP is the rare draw the pipeline was built to stop. The thousand cuts are the modal draw, and it waves them through.&lt;/p&gt;
&lt;p&gt;The quiet corner comes in three shapes. The scraper is the first: a schema correct at zero rows and fatal at eleven million, because the model optimizes from its training distribution, not your scale. Asked to partition a large table it reaches for &lt;code&gt;created_at&lt;/code&gt; in the primary key, the common corpus shape, not &lt;a class="link" href="https://explainanalyze.com/p/designing-partitioning-you-dont-have-to-babysit/" &gt;the primary-key partitioning that fits a high-scale OLTP table&lt;/a&gt;. The second is the value it computes wrong because it doesn&amp;rsquo;t hold your domain: &lt;a class="link" href="https://fortune.com/article/customer-support-ai-cursor-went-rogue/" target="_blank" rel="noopener"
 &gt;Cursor&amp;rsquo;s support bot&lt;/a&gt; invented a login policy that didn&amp;rsquo;t exist and users cancelled before anyone knew, and Air Canada &lt;a class="link" href="https://www.cbsnews.com/news/aircanada-chatbot-discount-customer/" target="_blank" rel="noopener"
 &gt;lost in court&lt;/a&gt; over a bereavement refund its chatbot made up. Move that same generator onto a write path computing a discount or a tax split and the row is well-typed and wrong about what the number means, with nothing reconciling it against the contract until quarter-end. The third is the change shipped past the author&amp;rsquo;s own understanding: it looked good and worked, so it went, and the judgment that would have caught it is built by the slow work the agent now skips. That is the &lt;a class="link" href="https://explainanalyze.com/p/the-paradox-of-the-fast-engineer/" &gt;paradox of the fast engineer&lt;/a&gt;, which &lt;a class="link" href="https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/" target="_blank" rel="noopener"
 &gt;a July 2025 study&lt;/a&gt; measured as 19% slower even as the developers felt faster.&lt;/p&gt;
&lt;h2 id="a-worked-example-the-soft-delete-leak"&gt;A worked example: the soft-delete leak
&lt;/h2&gt;&lt;p&gt;The grain bug is loud once you go looking, because it shows up as latency. The worse version of the same class is a write that corrupts a number and never moves a performance metric at all. Soft deletes are where this lives in most enterprise schemas.&lt;/p&gt;
&lt;p&gt;The convention is old and unwritten. A row is never physically removed; it gets a &lt;code&gt;deleted_at&lt;/code&gt; stamp, and every query that reads the table is expected to filter it out. A billing system for a SaaS company looks like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- one row per recurring line on an account: base plan, seats, add-ons
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;subscription_items&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BIGSERIAL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;sku&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;monthly_cents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;deleted_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- set when a customer drops the line
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;When a customer downgrades, the application does not delete the row, it stamps it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;subscription_items&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;deleted_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&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="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Every existing query that touches money knows this. The MRR rollup, the invoice generator, the revenue dashboard, all of them carry &lt;code&gt;AND deleted_at IS NULL&lt;/code&gt;, because the team learned years ago that forgetting it double-counts churned revenue. That knowledge lives in the queries and in the heads of the people who wrote them. It is nowhere in the schema; &lt;code&gt;deleted_at&lt;/code&gt; is just a nullable timestamp, and nothing stops a query from ignoring it.&lt;/p&gt;
&lt;p&gt;Now an agent is asked to add a board-facing metric, monthly recurring revenue by region, that the nightly ETL persists into the warehouse fact tables the dashboards read. It writes the obvious thing:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VIEW&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;mrr_by_region&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;si&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;monthly_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;mrr_cents&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;subscription_items&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;si&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;si&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deleted_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- remembered on accounts
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;region&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;It filtered &lt;code&gt;deleted_at&lt;/code&gt; on &lt;code&gt;accounts&lt;/code&gt; but not on &lt;code&gt;subscription_items&lt;/code&gt;, because nothing in the schema said it had to and the training corpus is full of joins shaped exactly like this. Every cancelled add-on and downgraded seat is now summed back into regional MRR, and each night the ETL reads this view and writes the inflated number into the warehouse the whole company reads as truth. The shape is right and the number is plausible, a little high, and growing as the soft-deleted pool grows.&lt;/p&gt;
&lt;p&gt;Nothing catches it. The engineer saw a working view and a &lt;code&gt;deleted_at&lt;/code&gt; filter sitting right there and moved on; the AI reviewer flagged a naming nit; the human skimmed the green summary and approved; CI ran on a seed database with almost no deleted rows, so the leak was a rounding error in the test. A missing predicate is an absence, the one thing every reviewer, human or model, reliably fails to see.&lt;/p&gt;
&lt;p&gt;The drift is the tell. Small at launch, ten or fifteen percent high a year later in the regions with the most downgrade history, and finance finds it the only way anyone does: reconciling the dashboard against billed revenue, a year in, with no single cause and no deploy to revert.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Warning&lt;/strong&gt;
 &lt;div&gt;A soft-delete filter is a contract the schema cannot enforce. Nothing in &lt;code&gt;subscription_items&lt;/code&gt; makes a query honor &lt;code&gt;deleted_at&lt;/code&gt;, so the rollup should have joined an &lt;code&gt;active_items&lt;/code&gt; view (&lt;code&gt;CREATE VIEW active_items AS SELECT ... WHERE deleted_at IS NULL&lt;/code&gt;) and a rule should forbid money queries from touching the base table at all. That is worth more than any amount of review attention, because the absence of a predicate is the one thing a reviewer, human or model, reliably fails to see.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="the-fix-is-a-business-call-not-a-technical-one"&gt;The fix is a business call, not a technical one
&lt;/h2&gt;&lt;p&gt;The mitigations are known and none of them are clever: reconcile against a source of truth on a cadence the business can stand, alarm on aggregates and drift instead of only errors, run CI against production-shaped volume, bake invariants into views and constraints. All worth doing, all secondary, because every one is a net thrown after the write has committed. The load-bearing decision is upstream of the tooling, and it is a positioning call leadership makes on purpose. Four honest positions:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Ship at full speed, accept the corner.&lt;/strong&gt; Take the velocity and take the unrecoverable write, the silent data loss, the bug the customer finds, as the cost. Legitimate for a seed-stage product with no prior state worth protecting. Catastrophic for a billing system.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Fast where it&amp;rsquo;s cheap, gated where it&amp;rsquo;s not.&lt;/strong&gt; Agents and junior engineers run on loud, recoverable surfaces (internal tooling, dashboards, throwaway analysis); writes that touch money, schema, or multi-writer tables go through someone who holds the domain. This is where Amazon landed the expensive way, requiring senior sign-off on AI-assisted changes to its sensitive stack after a run of incidents (&lt;a class="link" href="https://www.theregister.com/2026/04/29/aws_keynote_hypes_ai_magic/" target="_blank" rel="noopener"
 &gt;The Register, April 2026&lt;/a&gt;). They named the cost: controlled friction.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;SME on everything.&lt;/strong&gt; Only the smallest or most regulated shops can afford it, and it collapses into the second position the moment change volume outgrows the reviewers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Encode the domain into tests.&lt;/strong&gt; The one position that scales without scaling reviewers, and the one with the sharpest trap. An SME who knows the soft-delete convention writes an assertion that fails the build on the leak. Ask the agent to &amp;ldquo;add tests&amp;rdquo; under deadline and it writes one that sums the same leaked rows and asserts the inflated total is correct: the bug ships with a green check certifying it. And tests only check behavior, never whether the design should exist. The scraper passes everything you could write against it; the bug is the table, and a green suite is guaranteed to bless an architecture that works exactly as built.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The trap is choosing the second or fourth on a slide and the first in practice. All four only work if the people doing the reading still have judgment to read with, and the &lt;a class="link" href="https://explainanalyze.com/p/the-paradox-of-the-fast-engineer/" &gt;paradox of the fast engineer&lt;/a&gt; is draining that pool: hand the slow work that grows an SME to an agent for two years and the sign-off is staffed by people with the title and not the instinct. If you are not willing to lose your seniors, the budget item is the work that makes them, not just the headcount that has the rank today.&lt;/p&gt;
&lt;h2 id="what-your-monitoring-is-actually-for"&gt;What your monitoring is actually for
&lt;/h2&gt;&lt;p&gt;Everything you monitor fires in the loud quadrant. Error rates, &lt;code&gt;5xx&lt;/code&gt;, failed-job alerts, latency thresholds set above current numbers, all of it watching the corner you were already going to survive, because the DROP has a backup and a practiced runbook. The scraper that dies three months after a clean review, the write that computes the wrong number, the logic bug a customer found first, those never alarm, because the pipeline reads every change at the one moment it is still correct and then stops looking.&lt;/p&gt;
&lt;p&gt;A single one of those is not a crisis. You find it, you trace it, you fix it. The problem is rate. Each is one draw from the &lt;a class="link" href="https://explainanalyze.com/p/corruption-is-a-feature-not-a-bug-why-llms-corrupt-by-design/" &gt;corruption floor&lt;/a&gt;, and a team shipping ten thousand lines a day draws constantly, laying down a sediment of small wrongs that surface not the day they&amp;rsquo;re written but a year later, together, as a system nobody fully understands returning numbers nobody fully trusts. By then it is past untangling: a thread to pull assumes a thread, and a year of compounded cuts is the whole fabric. The options shrink to a rewrite or living with numbers you can&amp;rsquo;t defend, and no senior worth the title signs up to reverse-engineer a year of an agent&amp;rsquo;s confident guesses.&lt;/p&gt;
&lt;p&gt;The fake citations got caught because the judge knew the real ones. That is the whole job, and the agent can&amp;rsquo;t do it for you: someone has to ship nothing they don&amp;rsquo;t understand, and understand it the whole way down, what the value means and what it does to every system that reads it later. Your product has no judge unless you are one. The agent makes the drafts faster; knowing what they cost is still the part you can&amp;rsquo;t hand off.&lt;/p&gt;</description></item><item><title>Narrow Tools, Narrow Agents: Where Agent Reliability Actually Comes From</title><link>https://explainanalyze.com/p/narrow-tools-narrow-agents-where-agent-reliability-actually-comes-from/</link><pubDate>Sat, 30 May 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/narrow-tools-narrow-agents-where-agent-reliability-actually-comes-from/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Narrow Tools, Narrow Agents: Where Agent Reliability Actually Comes From" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;The agents that hallucinate least are the ones calling narrow, opinionated tools and given the smallest possible job. A &amp;ldquo;diagnostic&amp;rdquo; endpoint that returns a present-state boolean plus a lag number beats a generic &lt;code&gt;query(sql)&lt;/code&gt; surface, and a scoped agent with three tools beats a wide agent with twenty. Even with both halves in place, the output still has inconsistencies on some level. The goal is &amp;ldquo;hallucinates least&amp;rdquo;, not &amp;ldquo;right&amp;rdquo;.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Here is what an on-call agent posted to the incident channel when &lt;code&gt;pg_stat_replication.replay_lag&lt;/code&gt; on the &lt;code&gt;orders&lt;/code&gt; cluster crossed the 60s SLO:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Logical replication slot pub_orders_v1 is behind on the publisher.
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;restart_lsn has not advanced in the last 12 minutes. Recommend
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;dropping the subscription and recreating it, then advancing the
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;slot manually with pg_replication_slot_advance(). Runbook:
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;ops/runbooks/postgres/replica-lag-orders.md (last reviewed 2023-08).
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The slot the agent named was decommissioned twenty months earlier. This is what &lt;code&gt;pg_replication_slots&lt;/code&gt; returned the same minute on the same replica:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;=&amp;gt; SELECT slot_name, slot_type, active FROM pg_replication_slots;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; slot_name | slot_type | active
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-----------+-----------+--------
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;(0 rows)
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The summary was produced by an agent with RAG over the full ops repo and a standard &lt;code&gt;query(sql)&lt;/code&gt; MCP tool against the replica. The &lt;code&gt;orders&lt;/code&gt; cluster moved off logical replication in the Q3 2024 migration to streaming. Two paragraphs of an ADR titled &amp;ldquo;Move publication off logical replication&amp;rdquo; explain exactly why. The ADR lives in the same repo, indexed by the same embedding model, available to the same retrieval call. It ranked behind the 2023 runbook, behind two other runbooks for the same alert family, and behind a post-mortem from a different cluster entirely. The ADR&amp;rsquo;s vocabulary didn&amp;rsquo;t match the alert&amp;rsquo;s. The agent never read it. The cluster&amp;rsquo;s actual problem (a long autovacuum on the &lt;code&gt;orders_2026_05&lt;/code&gt; partition generating WAL faster than the replica could apply) was sitting one query away in &lt;code&gt;pg_stat_progress_vacuum&lt;/code&gt;. The agent&amp;rsquo;s summary never reached for it.&lt;/p&gt;
&lt;h2 id="what-a-smarter-model-wouldnt-have-fixed"&gt;What a smarter model wouldn&amp;rsquo;t have fixed
&lt;/h2&gt;&lt;p&gt;The familiar levers are a bigger context window, better embeddings, a smarter model. None of them touch the underlying mechanic. Embeddings retrieve by similarity, not by truth-value. The 2023 runbook scored highest because it talked about the exact alert, with the exact column names, in the exact phrasing the alert text used. A bigger window pulls in more competing documents, including more wrong ones. A better embedding model sharpens the same match against the same stale corpus. A smarter LLM produces a more confident summary on the wrong grounding. The retrieval surface is the problem, and the model is doing what models do.&lt;/p&gt;
&lt;p&gt;Anthropic&amp;rsquo;s &lt;a class="link" href="https://www.anthropic.com/engineering/writing-tools-for-agents" target="_blank" rel="noopener"
 &gt;Writing effective tools for agents&lt;/a&gt; (September 2025) makes the structural version of the same point: more tools and broader tools don&amp;rsquo;t improve agent outcomes. The team behind Claude found that purposefully narrower tools (&lt;code&gt;search_contacts&lt;/code&gt; over &lt;code&gt;list_contacts&lt;/code&gt;, with shaped responses) beat broader ones consistently, because agents struggle to extract signal from irrelevant context and burn tokens trying. The same shape applies one layer up. A &lt;code&gt;search_runbooks&lt;/code&gt; tool grounded against a corpus where half the docs are out of date is a broad tool dressed in narrow clothes. The narrowness has to live in what the tool actually returns.&lt;/p&gt;
&lt;h2 id="the-same-alert-through-a-narrow-tool"&gt;The same alert through a narrow tool
&lt;/h2&gt;&lt;p&gt;Here is what a &lt;code&gt;diagnose_replica_lag(cluster)&lt;/code&gt; endpoint returns when the same alert fires:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-json" data-lang="json"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;lagging&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;true&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;lag_seconds&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;47&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;replication_mode&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;streaming&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;publisher_lsn&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;F1/A2C3D400&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;replay_lsn&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;F1/A2B14C00&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;wal_replay_paused&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="kc"&gt;false&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;blocking_autovacuum&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;relation&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;orders_2026_05&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;phase&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;scanning heap&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;started_at&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;2026-05-24T02:18:11Z&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="nt"&gt;&amp;#34;duration_seconds&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;5421&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The agent reads present-state structured input. No runbook, no link, no freeform &amp;ldquo;here&amp;rsquo;s what this might mean&amp;rdquo;. The endpoint is the only thing in the stack that knows the cluster moved off logical replication. Whatever slot the response names (if any) is whatever the slot is called today. The blocking autovacuum is computed by joining &lt;code&gt;pg_stat_replication&lt;/code&gt; and &lt;code&gt;pg_stat_progress_vacuum&lt;/code&gt; on the server side, where the join is cheap and the freshness is guaranteed. The agent&amp;rsquo;s summary against this input says what the input says. There is no 2023 runbook in the prompt to retrieve from.&lt;/p&gt;
&lt;p&gt;The Unix mantra ports to agent tools more directly than most. Each tool does one function, does it well, and shapes its output for the consumer. The consumer is an agent with a token budget, no working memory between calls, and a fondness for whichever input most resembles a familiar pattern. The output has to be small, shaped, current, and complete enough that the agent&amp;rsquo;s job is to read it, not compose it.&lt;/p&gt;
&lt;p&gt;A schema introspector for an agent doing query work returns the active subset of the catalog: tables, columns, and indexes touched by queries in the last thirty days, with column types, foreign keys, and the indexes that have non-zero &lt;code&gt;idx_scan&lt;/code&gt; over the same window. It does not dump 4,200 rows of &lt;code&gt;pg_catalog.pg_class&lt;/code&gt; joined against &lt;code&gt;pg_attribute&lt;/code&gt;. The catalog carries years of accumulated noise: deprecated audit tables, the experiment from 2022 that never got cleaned up, the staging-only mirror copies. An agent given the full dump pattern-matches against the noise as readily as the signal.&lt;/p&gt;
&lt;p&gt;A &amp;ldquo;currently breaking&amp;rdquo; endpoint returns a pre-joined view of active alerts, the playbook each alert routes to, the affected service, the deploy SHA from the last fifteen minutes, and the on-call&amp;rsquo;s contact. It does not return three underlying APIs and trust the agent to compose the join. The join is the question. The tool encoded it. Re-deriving the join from raw sources every call is where the agent burns tokens and where the misattributions accumulate.&lt;/p&gt;
&lt;p&gt;Web search joins this set when the question is about something fresh. For a CVE on a specific Postgres minor, a &lt;code&gt;web_search(release_notes)&lt;/code&gt; tool grounded against pgsql-announce or a vendor advisory beats the same question routed against an internal RAG corpus where the relevant note doesn&amp;rsquo;t exist yet. Fresh source. Narrow scope. The tool encodes the question.&lt;/p&gt;
&lt;p&gt;The pattern across all four: the tool encodes the question, not the source. An agent calling &lt;code&gt;query(sql)&lt;/code&gt; gets to compose every question itself, including the badly-worded ones. An agent calling a diagnostic tool only asks the questions the diagnostic was designed for. That constraint is the feature.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Warning&lt;/strong&gt;
 &lt;div&gt;&amp;ldquo;Narrow&amp;rdquo; has to be narrow in what the tool returns, regardless of what it&amp;rsquo;s named. A &lt;code&gt;diagnose_replica_lag&lt;/code&gt; tool that runs &lt;code&gt;SELECT * FROM pg_stat_replication&lt;/code&gt; and dumps every column on every row is no better than letting the agent write the SQL itself. The shaping (which columns, with what projection, with what filtering, with what defaults) is where the reliability lives. A tool that returns 5,000 rows because nobody put a &lt;code&gt;LIMIT&lt;/code&gt; on the server side has handed the responsibility back to the model, which is the responsibility the tool existed to remove.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="narrow-tools-dont-help-a-wide-agent"&gt;Narrow tools don&amp;rsquo;t help a wide agent
&lt;/h2&gt;&lt;p&gt;Tools shaped for the right answer still get the wrong call from an agent given fifty of them and the instruction &amp;ldquo;you are the on-call assistant, help the human&amp;rdquo;. The model picks whichever tool pattern-matches best to whatever the input string looked like, and that pattern-matching is biased toward whichever description sounds most familiar. A scoped agent (one job, three tools, one decision to produce) has nothing else to reach for.&lt;/p&gt;
&lt;p&gt;Scope is what the agent is for, expressed narrowly enough that the right tool call is mechanical. &amp;ldquo;Triage a replica-lag page&amp;rdquo; is a scope. The tools are the diagnostic, the recent-deploys lookup, and the playbook. The agent calls them in order and produces the summary. &amp;ldquo;Help the engineer with whatever they ask&amp;rdquo; is not a scope, it is the absence of one. The same model with the same tools resolves the first job consistently and freestyles the second.&lt;/p&gt;
&lt;p&gt;Wide scope produces a failure that&amp;rsquo;s worse than picking the wrong tool. A wide-scope agent given a hybrid problem reaches for one of the relevant tools and silently drops the other half. The scoping decision lived in the model&amp;rsquo;s pattern-match against the input string, which is the worst place to put a decision the next on-call wants to debug six months from now. A scoped agent doesn&amp;rsquo;t have to decide. The scope already decided.&lt;/p&gt;
&lt;p&gt;The two halves compound. Narrow tools make the right call mechanical inside a scope. Narrow scope makes it obvious which tools the agent will call. A wide agent with narrow tools wastes the narrowness. A scoped agent with broad tools wastes the scope. The Redis diagnostic API in &lt;a class="link" href="https://explainanalyze.com/p/the-10x-is-real-on-internal-tools-youd-otherwise-never-ship/" &gt;internal tools are AI 10x&lt;/a&gt; is what the canonical version looks like in production: one endpoint, present-state answers (key counts, memory pressure, slow-log entries, replication offset), pre-shaped for whoever is asking. The agent never composes Redis commands. It calls the diagnostic and reads structured output. The same shape ports to Postgres, MySQL, Kafka, ES.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;Note&lt;/strong&gt;
 &lt;div&gt;The argument is about agents acting on production systems, where a bad tool call has operational consequences and the corpus the agent reads from has years of accumulated drift. Pure-text Q&amp;amp;A bots over a curated help-center corpus are a different regime. The corpus there is small, maintained, and the cost of a bad retrieval is the user reading a stale answer rather than the system running a destructive command. Retrieval is the right primitive in that regime. The argument here is that &amp;ldquo;retrieval over the production docs corpus&amp;rdquo; is the wrong primitive for an agent that&amp;rsquo;s going to act.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="when-this-doesnt-earn-its-keep"&gt;When this doesn&amp;rsquo;t earn its keep
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;One-shot scripts and ad-hoc analysis where the human reviews every output. The agent&amp;rsquo;s tool is &lt;code&gt;query(sql)&lt;/code&gt; against a sandbox; the human reads the result and discards it. The blast radius is the engineer&amp;rsquo;s own time.&lt;/li&gt;
&lt;li&gt;Low-stakes generative tasks. Commit messages, variable names, refactoring suggestions, the docstring for a private helper. The cost of a wrong output is noticing and editing.&lt;/li&gt;
&lt;li&gt;Greenfield code where there&amp;rsquo;s no accumulated context to be stale. The agent is the only author the repo has seen, the conventions are whatever it wrote yesterday, and there&amp;rsquo;s no two-year-old runbook to mis-retrieve.&lt;/li&gt;
&lt;li&gt;Small teams with a single agent and a single use case. Three tools is the size the agent already has. Building a narrowing layer for something already narrow is overkill.&lt;/li&gt;
&lt;li&gt;Genuinely exploratory questions where the agent is supposed to ask broadly. &amp;ldquo;What in the catalog looks unused&amp;rdquo; is a question that wants the full catalog, not the thirty-day-active subset. Exploratory questions need exploratory tools.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The engineering work has moved. The 2023 question was how to prompt the model better. The 2026 question is what tool the agent reaches for, and how small the job is that the harness hands it. Most of the reliability budget lives in those two surfaces and not in the model weights. A team adopting agents on production systems will spend more time building agent-shaped APIs and scoping per-task harnesses than tuning prompts, because that is where the floor on hallucination actually moves. The model is going to misattribute and fabricate and confidently quote the wrong thing on some fraction of calls regardless of how the harness is built. The tool the agent calls and the scope of the call are the surfaces the engineer controls. The 2023 runbook for a slot that doesn&amp;rsquo;t exist anymore stops being a problem when the agent never reads runbooks, only calls the diagnostic that knows what the cluster actually is today.&lt;/p&gt;</description></item><item><title>It's Almost Always the Queries, Part V: Disk Has Two Alarms, Not One</title><link>https://explainanalyze.com/p/its-almost-always-the-queries-part-v-disk-has-two-alarms-not-one/</link><pubDate>Wed, 27 May 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/its-almost-always-the-queries-part-v-disk-has-two-alarms-not-one/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post It's Almost Always the Queries, Part V: Disk Has Two Alarms, Not One" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;Two alarms ride the same dashboard tile: the disk filling up, and the disk slowing down. Both have query-level and schema-level fixes that hold for years. Capacity is partition-and-archive, not &lt;code&gt;DELETE&lt;/code&gt;. IOPS is covering indexes and the access patterns that go with them. The cloud&amp;rsquo;s autoscaling and burst credits mask both, and the bill is where the symptom finally surfaces.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;On-call gets paged on RDS I/O latency at 2pm Tuesday. The Datadog graph shows read latency at four times its baseline, write latency climbing in lockstep. The engineer on rotation bumps the instance from &lt;code&gt;db.t3.medium&lt;/code&gt; to &lt;code&gt;db.t3.large&lt;/code&gt;, latency drops back inside the SLO inside five minutes, the page closes, and the incident channel goes quiet. Three days later: same alert, same dashboard, same &amp;ldquo;fix.&amp;rdquo; Same five-minute window. By the fourth time, somebody pulls the &lt;code&gt;BurstBalance&lt;/code&gt; metric out of CloudWatch and the picture changes. The instance upgrade had not actually done anything to the workload. It had reset the gp2 burst-credit pool from zero back to full. The query mix was steady. The variable was the credit accounting, and the dashboard the team was looking at did not graph it.&lt;/p&gt;
&lt;h2 id="the-obvious-fix-and-why-it-buys-you-weeks"&gt;The obvious fix and why it buys you weeks
&lt;/h2&gt;&lt;p&gt;Reach for a bigger volume, more provisioned IOPS, a beefier instance class, or all three at once. Each lever is real, and during an active incident with revenue tied to checkout latency, the right call is often whichever one moves the graph fastest. They share a property the postmortem usually skips: each rents capacity proportional to the pattern underneath - the bloat that produces dead pages, the retention nobody set, the SELECT list that stopped being covered when a column got added. The cost recurs every time growth or concurrency pushes the workload back into the same shape. &lt;a class="link" href="https://explainanalyze.com/p/its-almost-always-the-queries-part-i-why-metal-doesnt-help/" &gt;Part I&lt;/a&gt; called this renting the bug. The disk case is two bugs sharing one alert.&lt;/p&gt;
&lt;h2 id="two-failure-modes-two-upstream-fixes"&gt;Two failure modes, two upstream fixes
&lt;/h2&gt;&lt;p&gt;The disk tile collapses two genuinely different problems into one number. The disk is filling, which is capacity. The disk is slowing, which is IOPS. They have different mechanics, different upstream causes, and different fixes that hold for years rather than weeks. The 2pm incident is almost always the IOPS one. The Friday-afternoon &amp;ldquo;we&amp;rsquo;re at 87% storage&amp;rdquo; thread is the capacity one. Same dashboard, two alarms, two posts&amp;rsquo; worth of mechanism.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Capacity is bloat plus growth, and the shape that holds up is partition-and-archive.&lt;/strong&gt; A team&amp;rsquo;s first instinct on a too-full disk is to write a &lt;code&gt;DELETE FROM events WHERE created_at &amp;lt; NOW() - INTERVAL '180 days'&lt;/code&gt; and ship it. The space does not come back. On PostgreSQL, &lt;code&gt;DELETE&lt;/code&gt; marks tuples dead and leaves them on the page; the space is reclaimed by &lt;code&gt;VACUUM&lt;/code&gt;, and &lt;code&gt;VACUUM&lt;/code&gt; only returns physical space to the OS when an entire trailing extent is empty (the &lt;code&gt;VACUUM FULL&lt;/code&gt; that does is an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; rewrite of the table, which is not a thing you run on a busy production system). On an UPDATE-heavy table, autovacuum can fall behind the dead-tuple production rate, and bloat grows unboundedly until somebody intervenes. InnoDB has its own version of the same problem: deletes and updates fragment the clustered index, and a long-running transaction (an analytics session left open, a misbehaving connection pool, an export that took longer than expected) pins the undo log via the history list and prevents purge from cleaning up. &lt;code&gt;SHOW ENGINE INNODB STATUS&lt;/code&gt; lists &amp;ldquo;History list length&amp;rdquo; precisely so you can spot the case where purge is losing.&lt;/p&gt;
&lt;p&gt;The pattern that holds: partition by date or by tenant, drop or detach old partitions on a schedule, offload the detached partitions to cheaper storage if compliance or analytics still need them. PostgreSQL declarative partitioning (PG 10+) with &lt;a class="link" href="https://github.com/pgpartman/pg_partman" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pg_partman&lt;/code&gt;&lt;/a&gt; handles the rotation; the extension&amp;rsquo;s background worker can create new partitions ahead of the curve and run the retention drop on a schedule with no external cron. &lt;code&gt;ALTER TABLE ... DETACH PARTITION&lt;/code&gt; turns a partition into a standalone table you can dump and drop, or move to a different tablespace on slower disk. MySQL has the same shape via native &lt;code&gt;PARTITION BY RANGE&lt;/code&gt; and &lt;code&gt;ALTER TABLE ... DROP PARTITION&lt;/code&gt;, which on InnoDB returns the space directly because each partition is its own tablespace. The space comes back instantly, instead of waiting on VACUUM and never quite catching up. The trade is schema churn upfront, and the rest of &lt;a class="link" href="https://explainanalyze.com/p/designing-partitioning-you-dont-have-to-babysit/" &gt;the partitioning post&lt;/a&gt; is what to know before you commit to a partition key you cannot easily change later.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;IOPS is access pattern, and more IOPS is the answer to the wrong question.&lt;/strong&gt; A query that is &amp;ldquo;well-indexed&amp;rdquo; can still saturate the disk if the index does not cover the SELECT list. The classic shape: a composite index on &lt;code&gt;(customer_id, status)&lt;/code&gt; happily serves &lt;code&gt;WHERE customer_id = $1 AND status = 'open'&lt;/code&gt;, but the SELECT projects &lt;code&gt;customer_id, status, total_cents, created_at&lt;/code&gt;, and the engine follows a heap pointer for each of the few thousand matching rows to fetch the columns the index does not contain. A thousand random heap fetches per call, multiplied by call volume, is an IOPS load that no amount of provisioning quietly absorbs. The plan looks correct. The dashboard reads &amp;ldquo;needs more IOPS.&amp;rdquo; &lt;a class="link" href="https://explainanalyze.com/p/covering-index-traps-when-adding-one-column-breaks-your-query/" &gt;The covering-index post&lt;/a&gt; walks the diagnostic in detail; the fix is &lt;code&gt;INCLUDE&lt;/code&gt; columns on PostgreSQL 11+ for the projection-only payload, or a reordered composite index on MySQL that puts the projected columns inside the index. Same query, two orders of magnitude fewer pages read, the heap-fetch count drops to zero in &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; output.&lt;/p&gt;
&lt;p&gt;A worked example with real numbers: a February 2026 write-up titled &lt;a class="link" href="https://frn.sh/iops/" target="_blank" rel="noopener"
 &gt;&amp;ldquo;Between select and disk&amp;rdquo;&lt;/a&gt; documents a single query reading 27,841 blocks (217 MB) to return zero rows - roughly 1,989 IOPS from a query that filtered everything out on the heap because a JSONB predicate could not be evaluated inside a B-tree on &lt;code&gt;account_id&lt;/code&gt;. A companion query did the same shape: 12,071 rows fetched, 107 MB, ~1,944 IOPS, zero rows returned. Combined, the two queries demanded ~3,900 IOPS against a 3,000 IOPS provisioned ceiling, with reads briefly hitting 3,668 IOPS as burst credits allowed. The fix was a GIN index that let the JSONB filter run inside the index scan, instead of after the heap fetch. The disk dashboard during the incident read &amp;ldquo;IOPS saturated&amp;rdquo;; the actual cause was an index that did not match the predicate.&lt;/p&gt;
&lt;p&gt;The query-side moves that keep an index covering: project the columns you actually need (an ORM defaulting to &lt;code&gt;SELECT *&lt;/code&gt; defeats coverage the moment any column lands outside the index), prefer keyset pagination over deep &lt;code&gt;OFFSET&lt;/code&gt; (a &lt;code&gt;LIMIT 50 OFFSET 100000&lt;/code&gt; reads a hundred thousand index entries to discard them and return the next fifty), match the index&amp;rsquo;s column order in &lt;code&gt;ORDER BY&lt;/code&gt; so the planner skips the sort, and write &lt;code&gt;WHERE&lt;/code&gt; predicates the planner can push down to the index leading column. &lt;a class="link" href="https://explainanalyze.com/p/non-sargable-predicates-how-a-function-in-where-kills-your-index/" &gt;Non-SARGable predicates&lt;/a&gt; is the third leg of this: a function on a column, a leading wildcard, an implicit cast from &lt;code&gt;bigint&lt;/code&gt; to text, and the engine evaluates per row instead of seeking, and the IOPS graph follows. Each of these is a query-level move with no schema change, and each removes IO that more provisioned IOPS would only hide.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The managed-cloud overlay produces false fixes.&lt;/strong&gt; Three behaviors on AWS, with analogues on Azure and GCP, make the disk dashboard easy to misread. The first is gp2 (and gp3, with different mechanics) and burst credits. A gp2 volume earns 3 I/O credits per GiB per second up to a 5.4-million-credit cap, sustains 3,000 IOPS while credits last, and falls to its baseline (as low as 100 IOPS on small volumes) when the pool drains. The &lt;a class="link" href="https://aws.amazon.com/blogs/aws/new-burst-balance-metric-for-ec2s-general-purpose-ssd-gp2-volumes/" target="_blank" rel="noopener"
 &gt;AWS blog post that introduced the BurstBalance metric in 2016&lt;/a&gt; is still the cleanest reference. A workload that has been steady for months can hit a credit wall during a backup window or an end-of-quarter report, and the latency graph tells you &amp;ldquo;the disk got slow&amp;rdquo; without showing you that the disk was throttled because the credit counter hit zero. Bumping the instance, or growing the volume, resets the picture. Three days later the credits drain again. Same incident, same fix, same cycle, and &lt;code&gt;BurstBalance&lt;/code&gt; is the metric that closes the loop.&lt;/p&gt;
&lt;p&gt;The second is Aurora&amp;rsquo;s no-disk-in-the-traditional-sense model. Aurora storage scales transparently to 128 TB, so the disk-full alarm never fires. On Aurora Standard, IO is billed per request, and the alert nobody sets is on the bill. In &lt;a class="link" href="https://aws.amazon.com/blogs/aws/new-amazon-aurora-i-o-optimized-cluster-configuration-with-up-to-40-cost-savings-for-i-o-intensive-applications/" target="_blank" rel="noopener"
 &gt;May 2023, AWS announced Aurora I/O-Optimized&lt;/a&gt;, a flat-rate pricing option that removes per-IO charges in exchange for a higher instance and storage rate. The break-even, per AWS&amp;rsquo;s own guidance, is roughly 25% of total Aurora spend going to IO; above that, I/O-Optimized wins, below that, Standard does. &lt;a class="link" href="https://aws.amazon.com/blogs/apn/how-vgs-achieved-cost-savings-on-amazon-aurora/" target="_blank" rel="noopener"
 &gt;VGS&amp;rsquo;s case study from May 2025&lt;/a&gt; puts numbers on it: their &lt;code&gt;Aurora:StorageIOUsage&lt;/code&gt; was 30–40% of daily Aurora cost, traced to a Monday cleanup cron job concentrating millions of I/O operations into one window, and the move to I/O-Optimized cut their overall Aurora bill by roughly 20%, which at their scale was hundreds of thousands of dollars per year. The point is not the calculator. The point is that on Aurora the failure mode is not a graph that goes red, it is an invoice line item that climbs, and the cause is the same access pattern that would have shown up as IOPS saturation on RDS.&lt;/p&gt;
&lt;p&gt;The third is RDS storage autoscaling. Enable it, and the disk-full alarm never fires because the volume grows automatically up to the configured ceiling. The bloat keeps growing, the retention policy still does not exist, and the issue surfaces six months later at finance review when the storage line is double what it was. Autoscaling is fine; running it without a retention policy underneath turns &amp;ldquo;we need to delete old data&amp;rdquo; into &amp;ldquo;we need to delete old data and reclaim a terabyte of provisioned storage we&amp;rsquo;re paying for.&amp;rdquo;&lt;/p&gt;
&lt;h2 id="what-this-costs"&gt;What this costs
&lt;/h2&gt;&lt;p&gt;Each upstream fix has trade-offs the postmortem should name out loud.&lt;/p&gt;
&lt;p&gt;Partition-and-archive is schema churn upfront and operational scaffolding forever. Partition key choice, query routing across detached partitions, and the rotation tooling itself are the trade-offs worth making in advance, and the &lt;a class="link" href="https://explainanalyze.com/p/designing-partitioning-you-dont-have-to-babysit/" &gt;partitioning post&lt;/a&gt; is the canonical reference for that decision pass. The thing to keep in mind here is that none of it looks urgent until the disk is full, and that is the wrong moment to design a partition strategy.&lt;/p&gt;
&lt;p&gt;Covering indexes are write amplification and storage overhead. Every &lt;code&gt;INSERT&lt;/code&gt; and &lt;code&gt;UPDATE&lt;/code&gt; to a covered column writes to every index that covers it; an &lt;code&gt;INCLUDE&lt;/code&gt; clause adds payload columns to the index leaf without making them part of the key, which keeps the index smaller than a wide composite but still means the leaf gets updated on every write to those columns. A covering index designed for today&amp;rsquo;s SELECT list ages out the moment the SELECT list grows; the same query pattern from &lt;a class="link" href="https://explainanalyze.com/p/covering-index-traps-when-adding-one-column-breaks-your-query/" &gt;the covering-index post&lt;/a&gt; reappears six months later when a new feature adds a column. Adding &lt;code&gt;INCLUDE&lt;/code&gt; columns interacts with PostgreSQL&amp;rsquo;s HOT update path too: HOT updates need the new tuple to fit on the same page and not modify any indexed columns, and a wider index payload combined with a fillfactor near 100% can starve the HOT optimization without changing any query. &lt;code&gt;ALTER TABLE ... SET (fillfactor = 90)&lt;/code&gt; for write-heavy tables is the standard accompaniment to wide covering indexes, and it is the easy thing to forget.&lt;/p&gt;
&lt;p&gt;Cloud-side moves are mostly upside, with one trap worth naming. Aurora I/O-Optimized&amp;rsquo;s break-even moves with workload. A cluster that was fine on Standard last quarter can cross the I/O-heavy threshold this quarter and nobody notices until the next bill review. AWS publishes &lt;a class="link" href="https://aws.amazon.com/blogs/database/estimate-cost-savings-for-the-amazon-aurora-i-o-optimized-feature-using-amazon-cloudwatch/" target="_blank" rel="noopener"
 &gt;an estimator using CloudWatch metrics&lt;/a&gt; for the recalculation; running it quarterly catches the drift.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Warning&lt;/strong&gt;
 &lt;div&gt;The most common partition-and-archive footgun is queries that span the archive boundary returning silently incomplete results. A report that used to read three years of data still asks for three years, the partition that holds year three has been detached and archived to S3, and the query returns two years of data with no error. Once is a bug. Recurring is an architecture problem. The fix is making the boundary explicit, either by routing historical queries to a federated view that includes the archive (&lt;code&gt;CREATE FOREIGN TABLE&lt;/code&gt; on PostgreSQL, or a UNION ALL against a separate archive schema), or by rejecting queries that ask for ranges the live table does not cover. Failing loud beats answering wrong.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="when-this-doesnt-apply"&gt;When this doesn&amp;rsquo;t apply
&lt;/h2&gt;&lt;p&gt;Three cases where the hardware reading is right and the schema reading is not.&lt;/p&gt;
&lt;p&gt;A working set that genuinely does not fit in RAM. If a hot table is 12 GB on an 8 GB instance and the top of &lt;code&gt;pg_stat_statements&lt;/code&gt; is dominated by reads against that table, no partition strategy and no covering index change the fact that the buffer cache is too small. The wait events tell the story: &lt;code&gt;IO:DataFileRead&lt;/code&gt; dominating the active-session-by-wait graph in &lt;a class="link" href="https://explainanalyze.com/p/its-almost-always-the-queries-part-iv-when-the-sort-spills/" &gt;Part IV&lt;/a&gt;&amp;rsquo;s terms. The fix is RAM, or a smaller working set, and &amp;ldquo;smaller working set&amp;rdquo; usually means partitioning so the active subset shrinks, which means the line between &amp;ldquo;more RAM&amp;rdquo; and &amp;ldquo;fewer rows&amp;rdquo; is fuzzier than the framing suggests.&lt;/p&gt;
&lt;p&gt;Snapshot or backup operations consuming live IOPS during a known window. If the latency spike lines up with the 2am backup window, or with a once-a-month consistency check, and the rest of the day is fine, the answer is scheduling and IO throttling rather than query optimization. RDS snapshots are incremental and cheap to take, but the first snapshot on a fresh volume and any major change to the dataset force a full sweep that competes with live traffic.&lt;/p&gt;
&lt;p&gt;A one-time migration off a system that should have moved to cheaper storage years ago. If the disk is full of 2017 data that nobody has read in three years, the fix is dumping it to S3 once and reclaiming the space, not designing a rotation strategy for data that is not being produced anymore. Partition-and-archive is for recurring patterns. One-off cleanup is one-off cleanup.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;Capacity and IOPS are the slowest-to-alert resources a relational database has, and on managed cloud the autoscaling, burst credits, and per-IO billing models hide the cause while the bill quietly absorbs the symptom. Fix-once strategies survive workload growth in a way that &amp;ldquo;bigger instance&amp;rdquo; does not: a partition rotation dropping a month every month is not less effective the year you triple traffic, and a covering index that touches zero heap pages is not less effective when the table grows tenfold. The diagnostic discipline is the same one running through &lt;a class="link" href="https://explainanalyze.com/p/its-almost-always-the-queries-part-ii-troubleshooting-steps/" &gt;Parts II–IV&lt;/a&gt;. Pull the top-10 from &lt;code&gt;pg_stat_statements&lt;/code&gt; by &lt;code&gt;total_exec_time&lt;/code&gt;, read the plan with &lt;code&gt;BUFFERS&lt;/code&gt;, check &lt;code&gt;BurstBalance&lt;/code&gt; and the storage autoscaling history before resizing the volume. The instance type is the last thing to change.&lt;/p&gt;</description></item><item><title>It's Almost Always the Queries, Part IV: When the Sort Spills</title><link>https://explainanalyze.com/p/its-almost-always-the-queries-part-iv-when-the-sort-spills/</link><pubDate>Thu, 21 May 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/its-almost-always-the-queries-part-iv-when-the-sort-spills/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post It's Almost Always the Queries, Part IV: When the Sort Spills" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;A database alerting on memory pressure is almost never a workload that needs more RAM. The dangerous allocation is per-sort, per-hash, per-connection and transient, so the instance-level memory graph never shows it until data size and concurrency line up at the same instant. The exception is a genuinely large working set with a low cache hit ratio, and that one is real.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;A reporting endpoint sorts orders by &lt;code&gt;created_at&lt;/code&gt; for an account&amp;rsquo;s quarterly export. Last quarter it ran in about 200 ms. This quarter it takes four seconds, and nobody changed the query. The &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; output is the whole story:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;span class="lnt"&gt;9
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-fallback" data-lang="fallback"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-- last quarter
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Sort (cost=8420.11..8556.34 rows=54492 width=84)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Sort Key: created_at
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Sort Method: quicksort Memory: 3072kB
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;-- this quarter
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;Sort (cost=41922.88..42698.05 rows=310068 width=84)
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Sort Key: created_at
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; Sort Method: external merge Disk: 24960kB
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The data grew, which is the one thing that always happens. The sort set crossed &lt;code&gt;work_mem&lt;/code&gt;, the executor stopped sorting in memory and started an &lt;code&gt;external merge&lt;/code&gt; against a temp file on disk, and a CPU-bound operation became an IO-bound one. No crash, no OOM kill, no page. The memory dashboards show nothing, because a temp file written by one backend for the duration of one sort is not a number that appears on an instance-level &amp;ldquo;RAM used %&amp;rdquo; graph. The query just got slow, and the only place the cause is visible is in a plan nobody ran.&lt;/p&gt;
&lt;h2 id="more-ram-doesnt-raise-work_mem"&gt;More RAM doesn&amp;rsquo;t raise work_mem
&lt;/h2&gt;&lt;p&gt;The reflex is to resize the box. The endpoint is &amp;ldquo;running out of memory,&amp;rdquo; the instance has 32 GB, so move it to 64 and the sort has room. It&amp;rsquo;s a clean story and it is wrong, for a reason that is structural rather than situational.&lt;/p&gt;
&lt;p&gt;The sort spilled because it exceeded &lt;code&gt;work_mem&lt;/code&gt;, and &lt;code&gt;work_mem&lt;/code&gt; is a fixed per-operation budget that has nothing to do with how much RAM the box has. The default is 4 MB. It is 4 MB on a 4 GB instance and 4 MB on a 256 GB instance. Doubling the instance does not move that number by a byte. MySQL&amp;rsquo;s per-thread buffers behave the same way: &lt;code&gt;sort_buffer_size&lt;/code&gt; defaults to 256 KB and stays 256 KB on a 4 GB box and on a 256 GB box, exactly as &lt;code&gt;work_mem&lt;/code&gt; does. The export&amp;rsquo;s sort set is 24 MB; it will spill on the bigger box exactly as it spilled on the smaller one, because the limit it crossed is a config value, not a quantity of physical memory. &lt;a class="link" href="https://explainanalyze.com/p/its-almost-always-the-queries-part-i-why-metal-doesnt-help/" &gt;Part I&lt;/a&gt; called this renting the bug. The memory case is the cleanest version of the metaphor: you can buy RAM the database is structurally unable to point at the operation that needs it.&lt;/p&gt;
&lt;p&gt;So the real fix looks like raising &lt;code&gt;work_mem&lt;/code&gt;. Set it to 64 MB, the export&amp;rsquo;s sort fits in memory, the endpoint is fast again. That works for the export. It also arms a multiplier across every other connection on the system, and the multiplier is the actual subject of this article.&lt;/p&gt;
&lt;h2 id="the-memory-the-dashboard-cant-see"&gt;The memory the dashboard can&amp;rsquo;t see
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;work_mem&lt;/code&gt; is not a global pool the server draws down. It is a per-operation allowance, granted independently to every sort, hash, and materialize node, in every query, in every session, at the same time. The PostgreSQL &amp;ldquo;Resource Consumption&amp;rdquo; documentation states the consequence directly:&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;Note that a complex query might perform several sort and hash operations at the same time, with each operation generally being allowed to use as much memory as this value specifies before it starts to write data into temporary files. Also, several running sessions could be doing such operations concurrently. Therefore, the total memory used could be many times the value of &lt;code&gt;work_mem&lt;/code&gt;.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;&amp;ldquo;Many times&amp;rdquo; is the load-bearing phrase, and it has three separate multipliers stacked inside it.&lt;/p&gt;
&lt;p&gt;The first is plan shape. A report query that joins four tables, aggregates, and sorts the result does not allocate &lt;code&gt;work_mem&lt;/code&gt; once. It allocates it per memory-using node: a hash for each hash join, a sort for the &lt;code&gt;ORDER BY&lt;/code&gt;, another for a &lt;code&gt;DISTINCT&lt;/code&gt;, a hash for the &lt;code&gt;GROUP BY&lt;/code&gt;. Four memory-using nodes in one plan is ordinary, not pathological. That single query&amp;rsquo;s peak is &lt;code&gt;4 × work_mem&lt;/code&gt;, not &lt;code&gt;work_mem&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The second is &lt;code&gt;hash_mem_multiplier&lt;/code&gt;. Since PostgreSQL 13 a &lt;code&gt;hash_mem_multiplier&lt;/code&gt; setting governs this, and since PostgreSQL 15 it defaults to 2.0, so hash-based nodes get a larger allowance than sort-based ones. The &amp;ldquo;Resource Consumption&amp;rdquo; docs: &amp;ldquo;The final limit is determined by multiplying &lt;code&gt;work_mem&lt;/code&gt; by &lt;code&gt;hash_mem_multiplier&lt;/code&gt;. The default value is 2.0, which makes hash-based operations use twice the usual &lt;code&gt;work_mem&lt;/code&gt; base amount.&amp;rdquo; A hash join in that plan is not budgeted at &lt;code&gt;work_mem&lt;/code&gt;. It is budgeted at &lt;code&gt;2 × work_mem&lt;/code&gt;, on a current default, before anyone has touched a setting.&lt;/p&gt;
&lt;p&gt;The third is parallel workers. A parallel sequential scan feeding a parallel hash or sort gives each worker its own &lt;code&gt;work_mem&lt;/code&gt; allocation for its slice of the work. A query with &lt;code&gt;max_parallel_workers_per_gather&lt;/code&gt; set to 4 can have five processes (the leader plus four workers) each holding a &lt;code&gt;work_mem&lt;/code&gt;-sized hash for the same node.&lt;/p&gt;
&lt;p&gt;Now do the arithmetic the way &lt;a class="link" href="https://explainanalyze.com/p/its-almost-always-the-queries-part-iii-when-the-cpu-is-pegged/" &gt;Part III&lt;/a&gt; did it with CPU-seconds. You raised &lt;code&gt;work_mem&lt;/code&gt; to 64 MB to stop the export from spilling. A moderately complex query has four memory-using nodes, two of them hashes that get the 2.0 multiplier. Call its peak a conservative &lt;code&gt;64 MB × 6&lt;/code&gt; once the hash multiplier is counted, roughly 384 MB for one execution. Your Rails app runs 300 worker processes, each holding a connection, and on a busy afternoon 80 of them are running a query of about that shape at the same instant. &lt;code&gt;384 MB × 80&lt;/code&gt; is just over 30 GB of transient sort and hash memory, none of it in &lt;code&gt;shared_buffers&lt;/code&gt;, none of it visible on the memory graph until the second it is all allocated at once. The instance has 32 GB. The export&amp;rsquo;s sort fit. The OOM killer arrives anyway, two weeks after the change, with no obvious deploy to blame, because the change that caused it was a config edit and the trigger was an ordinary Tuesday with slightly more concurrency than Monday.&lt;/p&gt;
&lt;p&gt;The arithmetic above assumes the plan goes as costed. But the planner chose that plan from estimates, not from the rows it would actually find. Sort versus no-sort, hash join versus merge join, HashAggregate versus GroupAggregate are all decided at planning time from row-count guesses in &lt;code&gt;pg_statistic&lt;/code&gt;. A stale estimate, or an &lt;code&gt;n_distinct&lt;/code&gt; that was always wrong, and the planner picks a memory-hungry plan accurate stats would have avoided, or under-sizes an allocation that blows past &lt;code&gt;work_mem&lt;/code&gt; at runtime. &lt;code&gt;GROUP BY&lt;/code&gt; is the sharp case. Before PostgreSQL 13, released &lt;a class="link" href="https://www.postgresql.org/docs/release/13.0/" target="_blank" rel="noopener"
 &gt;2020-09-24&lt;/a&gt;, a HashAggregate chosen on a too-low &lt;code&gt;n_distinct&lt;/code&gt; estimate had no disk fallback at all: the hash table grew past &lt;code&gt;work_mem&lt;/code&gt; with no bound and could OOM the server. PG13 added the spill, and its release notes state the new behavior plainly. Hash aggregation &amp;ldquo;was avoided if it was expected to use more than &lt;code&gt;work_mem&lt;/code&gt; memory&amp;rdquo;; now the plan can be chosen anyway, and &amp;ldquo;the hash table will be spilled to disk if it exceeds &lt;code&gt;work_mem&lt;/code&gt; times &lt;code&gt;hash_mem_multiplier&lt;/code&gt;.&amp;rdquo; An uncapped OOM became the same &lt;code&gt;external merge&lt;/code&gt; trade the export opened on, and the data never had to grow to trigger it. The estimate only had to go stale.&lt;/p&gt;
&lt;p&gt;The same structure runs on MySQL with different knob names. The per-thread buffers (&lt;code&gt;sort_buffer_size&lt;/code&gt;, &lt;code&gt;join_buffer_size&lt;/code&gt;, &lt;code&gt;read_rnd_buffer_size&lt;/code&gt;) are allocated per connection, and &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/memory-use.html" target="_blank" rel="noopener"
 &gt;the MySQL documentation is explicit&lt;/a&gt; that &lt;code&gt;join_buffer_size&lt;/code&gt; can be allocated more than once for a single query, once per join that cannot use an index. Multiply the per-thread total by &lt;code&gt;max_connections&lt;/code&gt; and you have MySQL&amp;rsquo;s version of the same blind multiplier. MySQL&amp;rsquo;s optimizer is just as estimate-bound: it picks join order and access paths from index cardinality (refreshed by &lt;code&gt;ANALYZE TABLE&lt;/code&gt;, stored under &lt;code&gt;innodb_stats_persistent&lt;/code&gt;) and optional column histograms, and stale cardinality pushes it onto a join that cannot use an index and falls back to a block-nested-loop on &lt;code&gt;join_buffer_size&lt;/code&gt;. Internal temporary tables add a second multiplier: under the MySQL 8.0 TempTable engine, an in-memory temp table grows until it hits &lt;code&gt;tmp_table_size&lt;/code&gt;, at which point, in the &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/internal-temporary-tables.html" target="_blank" rel="noopener"
 &gt;documentation&amp;rsquo;s words&lt;/a&gt;, &amp;ldquo;MySQL automatically converts the in-memory internal temporary table to an &lt;code&gt;InnoDB&lt;/code&gt; on-disk internal temporary table.&amp;rdquo; &lt;code&gt;temptable_max_ram&lt;/code&gt; (default 1 GiB) caps the engine&amp;rsquo;s total RAM before it spills to memory-mapped files. The MySQL spill is the same event as the Postgres &lt;code&gt;external merge&lt;/code&gt;, reached through a slightly different accounting path.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;Note&lt;/strong&gt;
 &lt;div&gt;This is also why &amp;ldquo;give the database more memory&amp;rdquo; so often goes to the wrong place. Told to add memory, teams enlarge &lt;code&gt;shared_buffers&lt;/code&gt; (or &lt;code&gt;innodb_buffer_pool_size&lt;/code&gt;). That is the buffer cache, fixed at server startup, and it does nothing for sorts and hashes, which allocate from a separate per-backend region. Worse, oversizing it starves the resource the database quietly depends on. PostgreSQL uses no direct IO; every page read goes through the operating system page cache, and RAM you did not hand to &lt;code&gt;shared_buffers&lt;/code&gt; or to backends is not idle, it is that cache. The &lt;a class="link" href="https://wiki.postgresql.org/wiki/Tuning_Your_PostgreSQL_Server" target="_blank" rel="noopener"
 &gt;PostgreSQL wiki tuning guide&lt;/a&gt; puts the starting point at &amp;ldquo;1/4 of the memory in your system&amp;rdquo; and warns that &amp;ldquo;it&amp;rsquo;s unlikely you&amp;rsquo;ll find using more than 40% of RAM to work better than a smaller amount,&amp;rdquo; precisely because the OS cache needs the rest. Every memory area in this article is a facet of one accounting problem: the instance metric sums physical pages, and none of the allocations that actually break the database are visible at that resolution.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The OOM killer is where the silent spill stops being silent. Linux overcommits memory by default: it hands out address space freely on the assumption that not every process touches all of it. When resident memory across the system exceeds RAM plus swap, the kernel invokes the OOM killer, which picks a victim by &lt;code&gt;oom_score&lt;/code&gt; and sends it &lt;code&gt;SIGKILL&lt;/code&gt;. If the victim is a PostgreSQL backend, the damage does not stop at that one connection. The &lt;a class="link" href="https://www.postgresql.org/docs/current/kernel-resources.html" target="_blank" rel="noopener"
 &gt;PostgreSQL documentation on managing kernel resources&lt;/a&gt; explains that the kernel &amp;ldquo;might terminate the PostgreSQL postmaster&amp;rdquo; outright, and even when it takes a backend instead, a backend killed by &lt;code&gt;SIGKILL&lt;/code&gt; had no chance to release its locks or detach cleanly from shared memory. The postmaster can no longer assume shared memory is consistent, so it does the only safe thing: it terminates every other backend, and the entire instance runs crash recovery. One report query, on one connection, restarts the whole database. MySQL gets to the same place by a shorter route. &lt;code&gt;mysqld&lt;/code&gt; is a single multi-threaded process, so the OOM killer has exactly one target; kill it and the entire server goes down at once, and on restart InnoDB runs crash recovery by replaying its redo log. Both engines end in a full restart, Postgres because the postmaster cascades the kill outward and MySQL because there was only ever one process to kill.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Warning&lt;/strong&gt;
 &lt;div&gt;This is the failure mode behind a real, dated incident. In a &lt;a class="link" href="https://mydbanotebook.org/posts/work_mem-its-a-trap/" target="_blank" rel="noopener"
 &gt;March 2026 write-up titled &amp;ldquo;work_mem: it&amp;rsquo;s a trap!&amp;rdquo;&lt;/a&gt;, PostgreSQL contributor Lætitia Avrot walked through a production cluster with 2 TB of RAM that the OOM killer reaped. &lt;code&gt;work_mem&lt;/code&gt; on that cluster was 2 MB, below the 4 MB default, not some reckless 1 GB. A single badly structured query accumulated allocations inside one &lt;code&gt;ExecutorState&lt;/code&gt; memory context faster than anything released them. The context&amp;rsquo;s dump showed 524,059 separate chunks. That memory is not freed until the operation finishes, and the operation never finished, so it climbed until 2 TB was gone. A 2 MB setting and a 2 TB box, and the box still lost. The problem was never the size of the box.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="fixes-and-what-each-one-costs"&gt;Fixes, and what each one costs
&lt;/h2&gt;&lt;p&gt;Do not raise &lt;code&gt;work_mem&lt;/code&gt; globally. The export needs 64 MB; the rest of the workload does not, and the global setting applies the change to every connection whether it needs it or not. Raise it where the big sort actually runs. &lt;code&gt;SET work_mem = '256MB'&lt;/code&gt; inside the reporting session, scoped to that transaction, or &lt;code&gt;ALTER ROLE analytics SET work_mem = '256MB'&lt;/code&gt; so the change attaches to the role the reports run as and the OLTP path keeps the small default. MySQL&amp;rsquo;s per-thread buffers are session-settable in the same way. Keep the &lt;code&gt;my.cnf&lt;/code&gt; global values for &lt;code&gt;sort_buffer_size&lt;/code&gt;, &lt;code&gt;join_buffer_size&lt;/code&gt;, and &lt;code&gt;tmp_table_size&lt;/code&gt; small, and &lt;code&gt;SET SESSION sort_buffer_size = ...&lt;/code&gt; on the reporting connection. The cost is that this requires knowing which workload is which. It assumes the reporting queries connect as a distinguishable role or run through a distinguishable code path, and on a system where the web app and the nightly export share one database user, that separation is work you have to do first, on either engine.&lt;/p&gt;
&lt;p&gt;A connection pooler bounds the other multiplier. The arithmetic above had 80 concurrent heavy queries because 300 app workers each held a real backend. Put PgBouncer in transaction mode in front, sized to a 40-connection pool, and the database can never run more than 40 backends no matter how many app workers exist. The multiplier is capped at 40 instead of 300. MySQL&amp;rsquo;s analogue is &lt;a class="link" href="https://proxysql.com/documentation/" target="_blank" rel="noopener"
 &gt;ProxySQL&lt;/a&gt;, an external connection multiplexer that fronts the server the way PgBouncer fronts Postgres, plus the &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/thread-pool.html" target="_blank" rel="noopener"
 &gt;thread pool plugin&lt;/a&gt; shipped in MySQL Enterprise Edition and in Percona Server, which caps the number of threads executing at once. Bounding concurrent threads bounds the per-thread-buffer multiplier the same way bounding backends bounds it on Postgres. &lt;a class="link" href="https://explainanalyze.com/p/its-almost-always-the-queries-part-iii-when-the-cpu-is-pegged/" &gt;Part III&lt;/a&gt; covered pool sizing for the CPU case, and the reasoning transfers exactly: a query that waits briefly for a pool slot and then runs is cheaper than one that starts immediately and helps exhaust memory. The cost is latency under burst, and a pool sized too small turns into its own incident when every slot is held and the queue backs up.&lt;/p&gt;
&lt;p&gt;The fix that removes the spill instead of feeding it is fixing the query. The export sorts by &lt;code&gt;created_at&lt;/code&gt;; an index on &lt;code&gt;(account_id, created_at)&lt;/code&gt; lets the planner return rows already in order and the &lt;code&gt;Sort&lt;/code&gt; node disappears from the plan entirely, no &lt;code&gt;work_mem&lt;/code&gt; consumed because no sort happens. Keeping statistics fresh is part of the same fix: autovacuum runs &lt;code&gt;ANALYZE&lt;/code&gt;, but a bulk load or a fast-growing table outruns it, and a manual &lt;code&gt;ANALYZE&lt;/code&gt; (or a raised per-column target via &lt;code&gt;ALTER TABLE ... ALTER COLUMN ... SET STATISTICS&lt;/code&gt;, or &lt;code&gt;CREATE STATISTICS&lt;/code&gt; for correlated columns) keeps the planner&amp;rsquo;s estimate close enough to reality that it sizes the plan correctly. MySQL&amp;rsquo;s equivalent is &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/analyze-table.html" target="_blank" rel="noopener"
 &gt;&lt;code&gt;ANALYZE TABLE&lt;/code&gt;&lt;/a&gt; for index cardinality and &lt;code&gt;ANALYZE TABLE ... UPDATE HISTOGRAM ON ...&lt;/code&gt; for column histograms, with &lt;code&gt;innodb_stats_auto_recalc&lt;/code&gt; governing whether InnoDB refreshes cardinality on its own. The diagnostic tell is the one &lt;a class="link" href="https://explainanalyze.com/p/its-almost-always-the-queries-part-ii-troubleshooting-steps/" &gt;Part II&lt;/a&gt; already named: &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; showing estimated rows and actual rows diverging by orders of magnitude means the planner is flying blind. A hash join&amp;rsquo;s allocation is proportional to the rows on its build side, so a predicate that filters earlier, or an index that avoids scanning rows the query then discards, shrinks the hash. This is the crossover with the rest of the series: a &lt;a class="link" href="https://explainanalyze.com/p/non-sargable-predicates-how-a-function-in-where-kills-your-index/" &gt;non-SARGable predicate&lt;/a&gt; that forces a scan also inflates every downstream sort and hash that scan feeds, and a &lt;a class="link" href="https://explainanalyze.com/p/covering-index-traps-when-adding-one-column-breaks-your-query/" &gt;covering index that quietly stopped covering&lt;/a&gt; when a column joined the &lt;code&gt;SELECT&lt;/code&gt; list adds heap fetches that widen the rows a sort has to buffer. Tuning the query to be smaller per call is the per-operation answer; Part V takes the IO side of it further.&lt;/p&gt;
&lt;p&gt;Leave &lt;code&gt;shared_buffers&lt;/code&gt; near the conventional fraction and do not raise it to &amp;ldquo;use the memory.&amp;rdquo; The page cache needs that headroom, and on a managed service the default is usually already in the sane range. The kernel side is worth one deliberate pass: &lt;code&gt;vm.overcommit_memory&lt;/code&gt; controls how freely Linux hands out address space, and setting the postmaster&amp;rsquo;s &lt;code&gt;oom_score_adj&lt;/code&gt; lower than its backends&amp;rsquo; (PostgreSQL ships &lt;code&gt;PG_OOM_ADJUST_FILE&lt;/code&gt; and &lt;code&gt;PG_OOM_ADJUST_VALUE&lt;/code&gt; for exactly this) means that when the OOM killer does fire, it reaps a single backend and not the supervisor. That converts a full crash-recovery restart into one dropped connection. It does not fix the multiplier; it makes the multiplier&amp;rsquo;s worst day cheaper. &lt;code&gt;vm.overcommit_memory&lt;/code&gt; is an OS-level setting that applies to both engines, but the per-process &lt;code&gt;oom_score_adj&lt;/code&gt; trick has nothing to work with on MySQL. There is no supervisor and worker split, so one &lt;code&gt;mysqld&lt;/code&gt; process means nothing to spare. For MySQL the levers are the system-wide overcommit setting and the discipline of not over-provisioning the per-thread buffers in the first place.&lt;/p&gt;
&lt;h2 id="when-more-ram-is-the-honest-answer"&gt;When more RAM is the honest answer
&lt;/h2&gt;&lt;p&gt;The thesis has real exceptions, and a staff engineer wants the boundary, not the slogan.&lt;/p&gt;
&lt;p&gt;A genuinely large working set is the first one. If the buffer-cache hit ratio is low and the wait events are dominated by &lt;code&gt;IO:DataFileRead&lt;/code&gt;, the database is going to disk because the data it needs does not fit in RAM, and that is a capacity problem that more memory honestly solves. The tell is in the waits, not the memory graph: steady IO waits on reads, a hit ratio that has been falling for weeks. This shades into Part V&amp;rsquo;s territory, where the line between &amp;ldquo;needs more RAM for cache&amp;rdquo; and &amp;ldquo;needs more IOPS&amp;rdquo; gets drawn properly.&lt;/p&gt;
&lt;p&gt;A correctly isolated analytical workload is the second. A reporting role that runs deliberate large sorts, on its own connection budget, behind a pooler that caps its concurrency, genuinely benefits from a high &lt;code&gt;work_mem&lt;/code&gt; for that role. Raising it there is not the bug this article describes. The bug is raising it globally and arming it across 300 OLTP connections. Scoped to a role that runs ten concurrent queries at most, a large &lt;code&gt;work_mem&lt;/code&gt; is a correct decision.&lt;/p&gt;
&lt;p&gt;And plainly low concurrency. A database with a 20-connection pool and a workload that never runs more than a handful of heavy queries at once has a small multiplier, and raising &lt;code&gt;work_mem&lt;/code&gt; globally on that system is safe because &lt;code&gt;work_mem × 20&lt;/code&gt; is a number the box can hold with room to spare. The multiplier is only dangerous when it is large. Measure it before assuming it is.&lt;/p&gt;
&lt;h2 id="the-number-that-isnt-on-the-graph"&gt;The number that isn&amp;rsquo;t on the graph
&lt;/h2&gt;&lt;p&gt;The memory graph on the instance dashboard is an honest number. It reports resident physical pages, sampled every minute, and it is the wrong instrument for this failure for the same reason the slow-query log was the wrong instrument in &lt;a class="link" href="https://explainanalyze.com/p/its-almost-always-the-queries-part-iii-when-the-cpu-is-pegged/" &gt;Part III&lt;/a&gt;. The slow-query log filters for queries that are individually expensive and misses the cheap query run a million times. The memory graph reports memory that is allocated right now and misses the allocation that is small per operation, multiplied by plan nodes, by the hash multiplier, by parallel workers, and by concurrent connections, and exists only in the instant all of those line up. The export sort that spilled to a 24 MB temp file and the 2 TB cluster the OOM killer reaped are the same failure at two scales: a per-operation cost the instance metric was never built to see. Resize the box and you move the ceiling that cost climbs toward. You do not change the cost, and the cost is set by the query and the connection count, the same two numbers every part of this series keeps coming back to.&lt;/p&gt;</description></item><item><title>It's Almost Always the Queries, Part III: When the CPU Is Pegged</title><link>https://explainanalyze.com/p/its-almost-always-the-queries-part-iii-when-the-cpu-is-pegged/</link><pubDate>Tue, 19 May 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/its-almost-always-the-queries-part-iii-when-the-cpu-is-pegged/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post It's Almost Always the Queries, Part III: When the CPU Is Pegged" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;A relational database pinned at 100% CPU is almost never running one expensive query. It&amp;rsquo;s running a cheap one too many times. The slow-query log and mean-time sorting both look right past it; &lt;code&gt;total_exec_time&lt;/code&gt; is the only view that finds it.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;A client portal has a status dropdown at the top of the orders page. It shows a count next to &amp;ldquo;Open&amp;rdquo;: &lt;code&gt;SELECT COUNT(*) FROM orders WHERE status = 'open' AND account_id = $1&lt;/code&gt;. For any given account that&amp;rsquo;s around 200 rows. The query runs in 0.4 ms. It has run in 0.4 ms for two years.&lt;/p&gt;
&lt;p&gt;Then marketing buys a Super Bowl ad. Thirty seconds of airtime, a short URL, and for the next twenty minutes the portal takes the kind of traffic it normally sees in a quarter. Every visitor lands on the orders page. Every page render fires that &lt;code&gt;COUNT(*)&lt;/code&gt;. The primary&amp;rsquo;s CPU graph goes from a comfortable 35% to a flat 100% ceiling and stays there. Checkout latency triples. The on-call engineer pulls up the slow-query log to find the offending statement and the log is empty. Nothing crossed the 100 ms threshold. The slowest query in &lt;code&gt;pg_stat_activity&lt;/code&gt; right now is 12 ms. Sorted by mean execution time, the dropdown count doesn&amp;rsquo;t appear until page four.&lt;/p&gt;
&lt;p&gt;It is, by a wide margin, the cheapest query in the system. It is also the entire problem.&lt;/p&gt;
&lt;h2 id="more-cpu-rents-the-bug"&gt;More CPU rents the bug
&lt;/h2&gt;&lt;p&gt;Resize the instance. Double the vCPUs, the graph drops from 100% to 55%, the incident closes. This works, and during a live traffic spike with revenue on the line it is often the correct first move. &lt;a class="link" href="https://explainanalyze.com/p/its-almost-always-the-queries-part-i-why-metal-doesnt-help/" &gt;Part I&lt;/a&gt; called this renting the bug, and the CPU case is the cleanest example of why the metaphor holds.&lt;/p&gt;
&lt;p&gt;The cost of that &lt;code&gt;COUNT(*)&lt;/code&gt; is linear in traffic. A box twice the size moves the ceiling, it does not change the slope. The dropdown still fires once per page render, the render count still tracks visitor count, and visitor count for a business that just discovered TV advertising only goes up. The next campaign, or organic growth over two quarters, walks the bigger box back to the same 100%. Each round of resizing buys time proportional to the headroom purchased, and the bill recurs.&lt;/p&gt;
&lt;p&gt;The other reflex is more replicas. For a read-heavy workload that genuinely is read-heavy, spreading reads across replicas is sound. It does not help here for a reason worth being precise about: the &lt;code&gt;COUNT(*)&lt;/code&gt; is not slow because the primary is contended. It is slow-in-aggregate because it does real work every single call, and that work executes on whatever node serves the query. Move it to a replica and the replica&amp;rsquo;s CPU pegs instead. You have not removed the work. You have bought another machine to do it.&lt;/p&gt;
&lt;h2 id="why-a-04-ms-query-saturates-a-core"&gt;Why a 0.4 ms query saturates a core
&lt;/h2&gt;&lt;p&gt;The arithmetic is the whole mechanism. A query that averages 0.4 ms and fires 50,000 times a minute consumes 50,000 × 0.0004 = 20 CPU-seconds of work every 60 seconds of wall-clock time. That&amp;rsquo;s one core, a third occupied, by one statement. Push the campaign traffic to 150,000 calls a minute and that statement alone wants a full core. Add the other queries the orders page fires (the order list, the account lookup, the session check) and a handful of cores disappear into a workload where no individual query is doing anything you&amp;rsquo;d call slow.&lt;/p&gt;
&lt;p&gt;This is why the slow-query log is the wrong instrument. A log thresholded at 100 ms is a filter for queries that are individually expensive. The CPU-bound failure mode is a population of queries that are individually trivial and collectively enormous. The log is working exactly as designed; it is designed to miss this. Mean execution time has the same blind spot. The dropdown count&amp;rsquo;s mean is 0.4 ms and will stay 0.4 ms while it burns four cores, because the mean says nothing about how often the query runs.&lt;/p&gt;
&lt;p&gt;The view that sees it is total execution time. &lt;code&gt;pg_stat_statements&lt;/code&gt; ordered by &lt;code&gt;total_exec_time&lt;/code&gt;, or MySQL&amp;rsquo;s &lt;code&gt;events_statements_summary_by_digest&lt;/code&gt; ordered by &lt;code&gt;SUM_TIMER_WAIT&lt;/code&gt;, multiplies per-call cost by call count, which is the number that actually maps to CPU consumed. Sort by that column and the dropdown &lt;code&gt;COUNT(*)&lt;/code&gt; is on the first row, with a call count an order of magnitude above anything else in the list. &lt;a class="link" href="https://explainanalyze.com/p/its-almost-always-the-queries-part-ii-troubleshooting-steps/" &gt;Part II&amp;rsquo;s Step 6&lt;/a&gt; is the procedure for getting there; this is the failure mode it was written for.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;Note&lt;/strong&gt;
 &lt;div&gt;&lt;code&gt;pg_stat_statements&lt;/code&gt; aggregates since the last &lt;code&gt;pg_stat_statements_reset()&lt;/code&gt; or server start, so the top of the list reflects history, not the current minute. During an incident, reset it and wait sixty seconds, or compare two snapshots a minute apart. A query that dominates a clean sixty-second window is the one burning CPU right now, not the one that happened to run a lot last Tuesday.&lt;/div&gt;
&lt;/div&gt;

&lt;h3 id="count-has-no-shortcut-and-thats-structural"&gt;COUNT(*) has no shortcut, and that&amp;rsquo;s structural
&lt;/h3&gt;&lt;p&gt;The reason this particular query does real work every call, rather than returning a cached number, is MVCC. Under multi-version concurrency control, two transactions running at the same instant can correctly see different row counts for the same table, because each sees the snapshot consistent with its own start. There is no single true count the database could cache and hand back. The &lt;a class="link" href="https://wiki.postgresql.org/wiki/Slow_Counting" target="_blank" rel="noopener"
 &gt;PostgreSQL wiki&amp;rsquo;s &amp;ldquo;Slow Counting&amp;rdquo; page&lt;/a&gt; states it plainly: PostgreSQL &amp;ldquo;must walk through all rows to determine visibility,&amp;rdquo; which &amp;ldquo;normally results in a sequential scan reading information about every row in the table.&amp;rdquo; &lt;a class="link" href="https://www.citusdata.com/blog/2016/10/12/count-performance/" target="_blank" rel="noopener"
 &gt;Citus&amp;rsquo;s 2016 write-up on counting performance&lt;/a&gt; puts the same point in one sentence: &amp;ldquo;There is no single universal row count that the database could cache, so it must scan through all rows counting how many are visible.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;InnoDB works the same way for the same reason. The MySQL documentation notes that InnoDB does not keep an internal stored row count, because a single counter cannot be correct for all transactions at once, and processes &lt;code&gt;COUNT(*)&lt;/code&gt; by traversing the smallest available index. This is the detail behind a stubborn piece of stale advice. MyISAM, the old default engine, did keep an exact row count in table metadata, so &lt;code&gt;SELECT COUNT(*)&lt;/code&gt; against a MyISAM table really was a constant-time metadata read. Advice written in that era (&amp;ldquo;COUNT(*) is free, don&amp;rsquo;t worry about it&amp;rdquo;) survived the engine that made it true. On InnoDB it is wrong.&lt;/p&gt;
&lt;p&gt;An index helps, with conditions. A &lt;code&gt;COUNT(*)&lt;/code&gt; filtered by an indexed column scans the index instead of the heap, and PostgreSQL&amp;rsquo;s index-only scan can satisfy a count from the index alone, but only for the pages the visibility map marks all-visible. The visibility map is maintained by &lt;code&gt;VACUUM&lt;/code&gt;. On a table with steady write traffic and autovacuum falling behind, a growing fraction of pages are not marked all-visible, the index-only scan falls back to heap fetches for those pages, and the count gets slower precisely when the table is busiest. The shortcut exists. It is conditional on vacuum keeping pace, and a write-heavy table under a traffic spike is the exact case where vacuum is least likely to be winning.&lt;/p&gt;
&lt;h2 id="the-cpu-bound-family"&gt;The CPU-bound family
&lt;/h2&gt;&lt;p&gt;The dropdown &lt;code&gt;COUNT(*)&lt;/code&gt; is the canonical case because it is so cheap per call that it defeats every individually-focused diagnostic. The same shape (cheap-or-medium per call, ridden to 100% by call volume or concurrency) shows up in four other forms worth recognizing on sight.&lt;/p&gt;
&lt;p&gt;The first is aggregation. &lt;code&gt;GROUP BY&lt;/code&gt;, &lt;code&gt;SUM&lt;/code&gt;, &lt;code&gt;AVG&lt;/code&gt;, &lt;code&gt;DISTINCT&lt;/code&gt;, and the dashboard rollups built on them spend CPU on hashing and sorting, and the work is proportional to rows scanned, not rows returned. A &amp;ldquo;revenue by region this month&amp;rdquo; tile that scans 4 million order rows to return 6 numbers does 4 million rows of work every time someone loads the dashboard. One analyst with the dashboard open and a 30-second auto-refresh is 2,880 full rollups a day. A team of forty analysts, each with it open, is a standing CPU load that has nothing to do with how many people are actually looking. The query is not slow. It is medium, and it runs constantly.&lt;/p&gt;
&lt;p&gt;The second is parallel-worker starvation. PostgreSQL runs large scans and aggregates across parallel workers drawn from a shared, server-wide pool capped by &lt;code&gt;max_parallel_workers&lt;/code&gt;. The &lt;a class="link" href="https://www.postgresql.org/docs/current/how-parallel-query-works.html" target="_blank" rel="noopener"
 &gt;PostgreSQL documentation on parallel query&lt;/a&gt; is explicit that workers come from one pool and &amp;ldquo;the requested number of workers may not actually be available at run time.&amp;rdquo; A few heavy analytics queries, each fanning out to &lt;code&gt;max_parallel_workers_per_gather&lt;/code&gt; workers, can drain that pool. Everything else then runs with fewer workers than the planner costed for, or serially. The symptom is strange: CPU pegged, yet many sessions appear to be waiting rather than working, because the plan they got is not the plan they were costed for.&lt;/p&gt;
&lt;p&gt;The third is the connection pool sized past the core count. A database with 16 cores can do at most 16 things at once. Point 400 active connections at it and the operating system time-slices 400 runnable processes across 16 cores, and an increasing share of every core&amp;rsquo;s time goes to context switching rather than query execution. The &lt;a class="link" href="https://www.pgbouncer.org/config.html" target="_blank" rel="noopener"
 &gt;PgBouncer documentation&lt;/a&gt; and the conventional sizing guidance both land near the same place: a pool sized close to the core count, often &lt;code&gt;(cores × 2)&lt;/code&gt;, beats a much larger pool. The &lt;a class="link" href="https://github.com/brettwooldridge/HikariCP/wiki/About-Pool-Sizing" target="_blank" rel="noopener"
 &gt;HikariCP &amp;ldquo;About Pool Sizing&amp;rdquo; page&lt;/a&gt; makes the underlying point bluntly: running two queries sequentially is always faster than time-slicing them across one core, and its Oracle benchmark cut response times from roughly 100 ms to 2 ms by shrinking a pool from 2,048 connections to 96. CPU reads as 100%, but a measurable fraction of it is the scheduler shuffling processes, not the database answering questions.&lt;/p&gt;
&lt;p&gt;The fourth is fan-out from a distributed service mesh. Picture twelve microservices, each running six instances, and every instance polling the database on its own timer for state it treats as live: a config row it reloads every few seconds, a routing table it re-reads on a schedule. Each of the seventy-two issues a modest, reasonable number of queries, and none of it looks alarming on that service&amp;rsquo;s own dashboard. The database underneath sees the sum, a baseline load that no team owns and no team&amp;rsquo;s monitoring displays. What makes this its own shape, rather than the volume problem restated, is that every caller is asking the identical question and getting back the identical answer. The dropdown &lt;code&gt;COUNT&lt;/code&gt; was parameterized per account, and every execution did different work for a different row. Here, seventy-two instances poll for one config row that reads the same for all of them, because the database is the single place every service agrees holds the truth. The work is real and redundant: seventy-one of those reads per tick exist only because nothing in front of the database kept the answer the seventy-second already fetched.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Warning&lt;/strong&gt;
 &lt;div&gt;The unread-count badge is the fan-out problem hiding in your own frontend. A &amp;ldquo;you have N notifications&amp;rdquo; badge in the global nav re-runs its &lt;code&gt;COUNT&lt;/code&gt; on every page load of every authenticated user. It is not one feature&amp;rsquo;s query, it is a tax on every route in the application. An ORM N+1 in a list view is the same pattern from the &lt;a class="link" href="https://explainanalyze.com/p/orms-are-a-coupling-not-an-abstraction/" target="_blank" rel="noopener"
 &gt;ORM coupling&lt;/a&gt;: one page render silently expands into one query per row, and at list-page volume that is a CPU load with no single slow statement to point at.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;There is a fifth shape that is per-call expensive rather than per-call cheap, and it belongs here because it pins CPU the same way: predicates the planner cannot turn into an index seek. A &lt;code&gt;LIKE '%term%'&lt;/code&gt; with a leading wildcard, a regex match, JSONB extraction in the &lt;code&gt;WHERE&lt;/code&gt; clause, a function wrapped around a column, an implicit type cast because the column is &lt;code&gt;bigint&lt;/code&gt; and the parameter arrived as text. Each forces the engine to evaluate an expression on every candidate row, which is CPU work that no amount of indexing removes until the predicate itself is rewritten. &lt;a class="link" href="https://explainanalyze.com/p/non-sargable-predicates-how-a-function-in-where-kills-your-index/" &gt;Non-SARGable predicates&lt;/a&gt; covers the rewrite. The reason it shares this article is the diagnostic: a non-SARGable filter under concurrency reads as CPU saturation, and &lt;code&gt;total_exec_time&lt;/code&gt; is again where it surfaces.&lt;/p&gt;
&lt;h2 id="fixes-and-what-each-one-costs"&gt;Fixes, and what each one costs
&lt;/h2&gt;&lt;p&gt;For the dropdown &lt;code&gt;COUNT(*)&lt;/code&gt;, the first question is whether the number needs to be exact. Often it does not. A status badge that says &amp;ldquo;Open: 204&amp;rdquo; is not measurably more useful than one that says &amp;ldquo;Open: ~200,&amp;rdquo; and an estimate is close to free. PostgreSQL&amp;rsquo;s planner statistics already hold a row estimate in &lt;code&gt;pg_class.reltuples&lt;/code&gt;; the &lt;a class="link" href="https://wiki.postgresql.org/wiki/Count_estimate" target="_blank" rel="noopener"
 &gt;PostgreSQL wiki&amp;rsquo;s &amp;ldquo;Count estimate&amp;rdquo; page&lt;/a&gt; gives the query and the caveat, that &lt;code&gt;reltuples&lt;/code&gt; is maintained by &lt;code&gt;VACUUM&lt;/code&gt; and &lt;code&gt;ANALYZE&lt;/code&gt; and is only as fresh as the last run. For a filtered count, parsing the row estimate out of &lt;code&gt;EXPLAIN&lt;/code&gt; output gets you a per-predicate estimate. The trade-off is accuracy: an estimate can be off by a few percent, and it is wrong to use one where the count drives a financial total or a correctness check.&lt;/p&gt;
&lt;p&gt;When the number must be exact, the choice is between caching it and maintaining it. A cached count (in Redis, or a materialized view refreshed on a schedule) turns thousands of &lt;code&gt;COUNT(*)&lt;/code&gt; executions into one, at the cost of staleness equal to the refresh interval. A counter table or a counter column, incremented and decremented by trigger or by application code, keeps the count exact and reads in constant time, and it moves the cost to writes. Every insert and delete now also writes the counter row, and if that counter is global, every writer contends on one row. That contention is its own CPU and lock problem, sometimes a worse one than the count you started with. Per-account or per-shard counters spread the contention; a global &amp;ldquo;total orders&amp;rdquo; counter concentrates it. The pragmatic middle for a UI badge is the bounded count: &lt;code&gt;SELECT COUNT(*) FROM (SELECT 1 FROM orders WHERE status='open' AND account_id=$1 LIMIT 100) t&lt;/code&gt;, which stops scanning at 100 and lets the interface render &amp;ldquo;99+&amp;rdquo;. The product question of whether anyone needs the exact number above 99 is usually answered &amp;ldquo;no&amp;rdquo; the moment you ask it.&lt;/p&gt;
&lt;p&gt;Aggregation rollups want to stop running at read time, and the obvious instrument is a materialized view. On an OLTP primary it is more dependency than it looks. &lt;code&gt;REFRESH MATERIALIZED VIEW&lt;/code&gt; still runs the full scan; it has only moved the work off the viewers and onto a schedule, on the same cores you are trying to protect, in periodic spikes rather than a steady drip. Plain &lt;code&gt;REFRESH&lt;/code&gt; holds an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock that blocks reads of the view until it finishes; &lt;code&gt;REFRESH ... CONCURRENTLY&lt;/code&gt; trades that lock for a mandatory unique index and a slower full recompute. Add a scheduler to run it and a staleness window to reason about, and a feature that looked like one line of DDL is a small system to operate. The version that actually removes the scan is a summary table the write path maintains: each order insert also bumps the per-region, per-day total, so the dashboard reads a few pre-computed rows and the 4-million-row scan never runs. The cost shifts onto writes, a couple of extra row updates per transaction, and the numbers stay current with no refresh job at all. Where the data tolerates lag, the other move is to get the aggregation off the primary: point the dashboard&amp;rsquo;s &lt;code&gt;GROUP BY&lt;/code&gt; at a read replica so the scan burns the replica&amp;rsquo;s cores, or feed a separate analytics database and let the heavy rollups live there. A revenue tile that updates every five minutes is fine; an inventory count a customer sees at checkout usually is not.&lt;/p&gt;
&lt;p&gt;Parallel-worker starvation is a sizing problem. Cap &lt;code&gt;max_parallel_workers_per_gather&lt;/code&gt; so a single analytics query cannot drain the pool, and size &lt;code&gt;max_parallel_workers&lt;/code&gt; against the cores you can spare after the OLTP workload has what it needs. The connection-pool case is the same discipline: size the pooler near the core count rather than near peak concurrency, and let connections queue briefly in the pooler instead of oversubscribing the scheduler. Both fixes feel like throttling, and they are. A query that waits 5 ms for a pool slot and then runs at full speed beats one that starts immediately and fights 399 others for a core.&lt;/p&gt;
&lt;p&gt;Fan-out has two fixes, and which applies depends on whether the callers want the same answer. When they do - a config row, a feature-flag set that every instance reads identically - a cache in front of the database is the direct fix. One instance&amp;rsquo;s read populates a shared cache, the other seventy-one read from the cache, and the database serves the query once per TTL instead of once per instance per tick. When the callers genuinely need different answers, no single query is wrong and the lever is governance: a platform-level view of aggregate QPS broken down by calling service, and a per-service query budget treated as a real constraint. Health checks can poll every 30 seconds instead of every 2. Polling can become a push. None of that happens without someone holding the number that no individual team&amp;rsquo;s dashboard shows.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;Note&lt;/strong&gt;
 &lt;div&gt;Across every fix here, the move is the same: do the work fewer times. Estimate instead of count, cache instead of recompute, refresh on a schedule instead of per-view, poll less often, queue instead of oversubscribe. Tuning a query to be faster per call is the Part IV and Part V conversation. The CPU-bound failure is a volume problem, and volume is what these fixes attack.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="when-it-really-is-the-load"&gt;When it really is the load
&lt;/h2&gt;&lt;p&gt;Sometimes the workload genuinely needs the cores. A reporting database that runs heavy analytical queries, an ETL window, a system doing real per-row computation that no rollup can precompute because the parameters change every call, will sit at high CPU because that is the job. The tell is in &lt;code&gt;total_exec_time&lt;/code&gt;: when the top of the list is a spread of genuinely heavy statements rather than one trivial query with an enormous call count, you are looking at a workload that wants capacity, and adding cores is the honest answer. The diagnostic distinguishes the two cases; the dropdown &lt;code&gt;COUNT(*)&lt;/code&gt; at the top of the list means volume, a flat distribution of expensive queries means load.&lt;/p&gt;
&lt;p&gt;And the one-day spike can be a legitimate reason to rent. A Super Bowl ad, a product launch, a Black Friday window: a known, bounded surge where the cost of engineering a permanent fix before the date exceeds the cost of a bigger box for 48 hours. Scale up Friday, scale down Monday, fix the &lt;code&gt;COUNT(*)&lt;/code&gt; in the next sprint with the incident graph as the justification. That is renting the bug on purpose, with a return date. The failure is renting it by reflex, with no return date and no ticket, so the bigger box becomes the permanent baseline and the next spike starts the cycle again.&lt;/p&gt;
&lt;p&gt;The dropdown count on the orders page was always going to break. The Super Bowl ad only decided the date. A query that does work proportional to traffic, on a system whose traffic only grows, has a ceiling it will reach; the box size sets the date, and the query sets the slope. Sort by &lt;code&gt;total_exec_time&lt;/code&gt; before you size the instance, and you find out which one you&amp;rsquo;re actually fighting.&lt;/p&gt;</description></item><item><title>The Paradox of the Fast Engineer</title><link>https://explainanalyze.com/p/the-paradox-of-the-fast-engineer/</link><pubDate>Mon, 18 May 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/the-paradox-of-the-fast-engineer/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post The Paradox of the Fast Engineer" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;The judgment that lets an engineer override a model is built in the slow work the model now offers to do for them. Accept enough of that help on the work that would have built the judgment, and the agent&amp;rsquo;s speed arrives without the quality, security, scalability, maintainability, or operational sense that the slow work used to deposit alongside the code.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Three months after shipping, customers start complaining that menu items are missing from the navigation. The query that builds the menu does three &lt;code&gt;LEFT JOIN&lt;/code&gt;s against a self-referencing &lt;code&gt;categories&lt;/code&gt; table. The agent produced that shape when the engineer described the requirement; review passed because the test fixtures were three levels deep. Production grew to seven. The query was silently truncating subcategories the day it shipped, and the engineer who accepted the output had never reached for a recursive CTE, because nobody on the team had ever shown them one.&lt;/p&gt;
&lt;p&gt;The fix is a recursive CTE with &lt;code&gt;UNION ALL&lt;/code&gt;, anchored on the root row and joining the source table back to itself until no more rows come out. Five lines. Both shapes are valid SQL; the one that holds up against arbitrary depth is the one the engineer reaches for only after seeing it before. Without that prior, the idiom isn&amp;rsquo;t in their decision space. They can&amp;rsquo;t ask the agent for it, and they wouldn&amp;rsquo;t recognize it as the right answer if the agent offered it. No memory of a broken version that lacked it, no internal alarm that &amp;ldquo;three &lt;code&gt;LEFT JOIN&lt;/code&gt;s against a tree&amp;rdquo; is the shape of a future incident.&lt;/p&gt;
&lt;h2 id="the-obvious-fix-isnt-the-fix"&gt;The obvious fix isn&amp;rsquo;t the fix
&lt;/h2&gt;&lt;p&gt;Review the agent&amp;rsquo;s code before approving it. True, and insufficient. The reviewer who has never written a tree walk over a self-referencing table doesn&amp;rsquo;t know what they should be looking for. They see SQL that compiles, returns rows on the test data, and matches the shape of the request. The internal alarm that says &amp;ldquo;this assumes a fixed depth, what happens when the tree is deeper than the joins&amp;rdquo; doesn&amp;rsquo;t come from reading SQL. It comes from writing the broken version yourself, watching it fail in production, and tracing the failure back through your own assumption.&lt;/p&gt;
&lt;p&gt;Code review without that prior pain is pattern matching against the surface of the query. The bugs that ship through review are the ones where the surface looks right.&lt;/p&gt;
&lt;h2 id="the-paradox"&gt;The paradox
&lt;/h2&gt;&lt;p&gt;Here is the paradox. The judgment that lets an engineer override the model is built in the slow work the agent now offers to do for them. The engineer who accepts the output, reviews it briefly, and ships it has gotten the speed. They have not gotten the read on whether the query holds under the production tree shape, the security sense for whether the patch closed the CVE without invalidating something downstream, the scalability instinct for whether the join multiplies under real data, the maintainer&amp;rsquo;s eye for whether this diff just doubled the toil bill six months from now, or the operational feel for which parts of the system are load-bearing and which are decoration. None of those come bundled with the agent&amp;rsquo;s output. The five-minute version of the recursive CTE problem passes through them without depositing anything, the way watching someone debone a chicken on YouTube does not teach you when the knife is sharp enough.&lt;/p&gt;
&lt;p&gt;The pattern shows up in the public data. METR ran a &lt;a class="link" href="https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/" target="_blank" rel="noopener"
 &gt;controlled study in July 2025&lt;/a&gt; on sixteen experienced open-source developers working in repositories averaging more than a million lines and a decade old. The developers self-reported a 20% speedup from AI assistance. Measured against the control, they were 19% slower. A forty-point gap between what the engineer feels and what the stopwatch records, on a population that does this work for a living.&lt;/p&gt;
&lt;p&gt;Google&amp;rsquo;s &lt;a class="link" href="https://dora.dev/research/2025/dora-report/" target="_blank" rel="noopener"
 &gt;2025 DORA report&lt;/a&gt; found 90% of developers using AI and over 80% reporting it made them more productive, while organizational delivery metrics stayed roughly flat for teams without strong measurement practices. The same report measured bugs per developer up 54% and the median time a pull request spends in review up 441%. The verification work the agent created moved to the reviewer. The reviewer is now the bottleneck the agent isn&amp;rsquo;t helping, and the skill that makes a reviewer fast (the recognition of which agent-generated PR is hiding a fixed-depth assumption, or a missing index, or a quietly invalidated invariant) is the skill the same reviewer is no longer building by writing the slow version themselves.&lt;/p&gt;
&lt;p&gt;Cloudflare&amp;rsquo;s &lt;a class="link" href="https://blog.cloudflare.com/cyber-frontier-models/" target="_blank" rel="noopener"
 &gt;Project Glasswing&lt;/a&gt; write-up lands on the same shape from the security side. When they let a security-focused model write its own patches against live infrastructure code, the fixes &amp;ldquo;fixed the original bug while quietly breaking something else the code depended on.&amp;rdquo; The thing standing between those patches and production was a senior engineer who could read a regression suite and notice when a patch had quietly invalidated a load-bearing assumption. That recognition was built over years of debugging exactly that class of mistake. The model has no way to produce the recognition, and accepting the patch without it means shipping the regression and learning nothing in the process.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;Note&lt;/strong&gt;
 &lt;div&gt;None of this is saying the agent is useless. Its reliable surface is pattern-matching across volume, the way grep is reliably better than reading the whole file when you already know what string you&amp;rsquo;re looking for. Surfacing every place a deprecated API is called across a million-line repo. Pulling the regex syntax you&amp;rsquo;d otherwise have to look up. Flagging the four files in a 200-file diff that touched the auth path. The agent is a faster grep against language, and on that narrow ground it earns its seat. What is being sold and billed for, though, is autonomous production, and the autonomous-production claim does not survive the METR result above. The agent is nowhere near human decision-making, and the cost of treating its output as if it were is exactly the gap between the perceived 20% speedup and the measured 19% slowdown.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="the-slow-onset-failure"&gt;The slow-onset failure
&lt;/h2&gt;&lt;p&gt;The damage falls hardest on engineers who came up after the tools landed. The current cohort of senior engineers built their judgment in a decade when the slow work was the only available path. Every recursive query was a recursive query they had to figure out. Every migration was one they had to plan. Every 2 a.m. incident was one they had to root-cause without a model offering a first-guess hypothesis (&lt;a class="link" href="https://explainanalyze.com/p/your-alert-triage-doesnt-need-an-autonomous-agent/" &gt;Alert Triage Without an Agent&lt;/a&gt; goes deeper on that specific muscle). The path that produced today&amp;rsquo;s seniors ran straight through the slow work the agent now does on demand.&lt;/p&gt;
&lt;p&gt;Juniors who let the agent do that work will not arrive at the same place by the same route. Three years of accepting every agent PR, and the engineer who used to be a junior in their codebase is still a junior in their codebase, except now the codebase has grown more complex and the parts they don&amp;rsquo;t understand have grown faster than the parts they do. The gap doesn&amp;rsquo;t show on day one, or month six, or even year two. It shows the first time the agent produces output the engineer cannot evaluate: when the question a senior would ask about a migration is one the junior doesn&amp;rsquo;t know to ask, or when the bug in the agent&amp;rsquo;s PR is invisible to anyone who hasn&amp;rsquo;t written the broken version themselves (see also &lt;a class="link" href="https://explainanalyze.com/p/what-ai-gets-wrong-about-your-database/" &gt;What AI Gets Wrong About Your Database&lt;/a&gt; for the database-specific shape of this).&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Warning&lt;/strong&gt;
 &lt;div&gt;By the time the gap shows, it has been compounding for years. The engineer is on the wrong side of a hiring market that pays for exactly the recognition they no longer have, and nothing in a quarterly performance review catches the deposit you didn&amp;rsquo;t make to your own long-term memory.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="the-calibration"&gt;The calibration
&lt;/h2&gt;&lt;p&gt;The skill worth building is knowing which work the agent should do, which work you should do by hand, and which work you should accept from the agent and then rewrite anyway to internalize the pattern.&lt;/p&gt;
&lt;p&gt;The agent is the right tool for work where the context you&amp;rsquo;d gain by doing it yourself is marginal. Boilerplate. Syntax you&amp;rsquo;d otherwise look up. Test scaffolding for code paths you already understand. The migration template you&amp;rsquo;ve written for the tenth time this year. The fifty-line helper that&amp;rsquo;s mechanically obvious once you&amp;rsquo;ve decided what it should do. Let the agent handle these with a brief review and move on.&lt;/p&gt;
&lt;p&gt;The agent is the wrong tool for work where the context is the asset. The parts of the system you don&amp;rsquo;t yet understand. New code paths through a critical module. Database changes whose consequences you&amp;rsquo;d want to feel in your fingers before approving them in production. The first recursive CTE against a tree-shaped table you&amp;rsquo;ve never queried before. The first incident in a class of failures you haven&amp;rsquo;t seen, where the agent&amp;rsquo;s hypothesis is a hypothesis you should also be forming yourself. Do this work by hand, even when the agent would have produced a working diff faster. The slow version is what builds the alarm that catches the agent&amp;rsquo;s mistake the next time the same shape of work shows up.&lt;/p&gt;
&lt;p&gt;The hard part is the middle. Work that&amp;rsquo;s neither pure boilerplate nor entirely novel. Some of it belongs in a tight loop where you drive and the agent assists on syntax. Some gets reviewed line by line as a learning exercise rather than a compliance step. The rest gets rewritten by hand after the agent produces a working version, just to deposit the pattern in your own muscle. The choice turns on whether the work sits in a part of the codebase you need to know deeply or one you can afford to treat as a black box.&lt;/p&gt;
&lt;h2 id="when-this-doesnt-apply"&gt;When this doesn&amp;rsquo;t apply
&lt;/h2&gt;&lt;p&gt;The argument cuts cleanest for engineers building depth in a domain they intend to stay in. A platform engineer who needs to know the database. A security engineer building the recognition Cloudflare&amp;rsquo;s example demands. A backend engineer whose career bet is on a specific stack. A frontend engineer whose framework just shipped &lt;a class="link" href="https://vercel.com/changelog/next-js-may-2026-security-release" target="_blank" rel="noopener"
 &gt;thirteen advisories in a coordinated security release&lt;/a&gt; (auth bypass, SSRF, i18n path bypass, an RSC DoS hitting every App Router deployment on 13.x through 16.x) and who needs to read their own dependency graph well enough to know whether they were exposed. For these engineers, the slow work is the investment that pays back over the next decade.&lt;/p&gt;
&lt;p&gt;It cuts less cleanly for work that doesn&amp;rsquo;t depend on depth. The hobbyist exploring a new language. The throwaway script that ships in an afternoon and dies in a week (&lt;a class="link" href="https://explainanalyze.com/p/the-10x-is-real-on-internal-tools-youd-otherwise-never-ship/" &gt;The 10x Is Real, on Internal Tools You&amp;rsquo;d Otherwise Never Ship&lt;/a&gt; covers that end of the spectrum). The pre-product-market-fit startup whose entire codebase is throwaway in expected value, where vibe-coding the MVP and finding out if anyone wants the product is the rational trade against the dominant risk of nobody wanting it. The bill on that last case comes if the product wins, in the form of hiring engineers who can read the agent&amp;rsquo;s output and untangle the parts that now have to scale. That is a problem to have.&lt;/p&gt;
&lt;p&gt;It also doesn&amp;rsquo;t apply where the agent&amp;rsquo;s baseline beats what the company can actually hire at its price point. The frontier model is mediocre in absolute terms (METR again), but it is a consistent floor, and not every company can outhire that floor at the salaries they actually pay. In those shops the cheaper path is to let the model produce and have a senior reviewer (often a contractor) clean up after it. The agent there is competitive at the level the company can afford, which sits below senior judgment but above the median hire the budget will permit.&lt;/p&gt;
&lt;p&gt;Senior engineers who already have the context sit outside the trap entirely. The one who has written the recursive CTE a dozen times can accept the agent&amp;rsquo;s first-draft query and review it competently because the alarm is already wired. The asymmetry is that the trap falls hardest on the engineers least equipped to recognize it.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;The market for engineering judgment is splitting. Work the model can do at the level of a competent mid-level engineer is being commoditized; work that requires the judgment to recognize when the model is wrong is being concentrated. Which side an engineer ends up on is determined less by the tools they use than by which work they choose to do by hand.&lt;/p&gt;
&lt;p&gt;The senior&amp;rsquo;s value is going up because the volume of model output needing adult supervision grew faster than the supply of adults to supervise it. The junior&amp;rsquo;s floor is the level the model now hits without help. The path from one to the other used to be the slow work, and the path is still the slow work, except the slow work is now optional and most engineers will not opt in.&lt;/p&gt;</description></item><item><title>It's Almost Always the Queries, Part II: Troubleshooting Steps</title><link>https://explainanalyze.com/p/its-almost-always-the-queries-part-ii-troubleshooting-steps/</link><pubDate>Sun, 17 May 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/its-almost-always-the-queries-part-ii-troubleshooting-steps/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post It's Almost Always the Queries, Part II: Troubleshooting Steps" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;Database troubleshooting is a learnable skill with a repeatable sequence: observe what&amp;rsquo;s happening now, categorize the wait, narrow to the specific cause, then act. The sequence matters more than the tools. This article walks through it for engineers who don&amp;rsquo;t do this often.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Your APM lights up. Endpoint latency on &lt;code&gt;/api/checkout&lt;/code&gt; has tripled in the last three minutes. The graph shows a wall of slow requests, no deploy in the window, no traffic spike. Something changed in the database layer. You have maybe fifteen minutes before someone senior asks what&amp;rsquo;s happening. What do you actually do?&lt;/p&gt;
&lt;p&gt;If your instinct is to paste the alert into an LLM and ask what to do, pause. The model will give you something that looks like an answer. It might suggest killing the longest-running query, or restarting the connection pool, or adding an index. It pattern-matches on symptoms (duration, state labels, error messages) without access to the causal structure underneath. If you don&amp;rsquo;t understand why it&amp;rsquo;s suggesting what it&amp;rsquo;s suggesting, you can&amp;rsquo;t tell when it&amp;rsquo;s wrong. This applies even if you&amp;rsquo;re running an agent with MCP access to your database (hopefully read-only). The agent can query &lt;code&gt;pg_stat_activity&lt;/code&gt; faster than you can type it, but if you don&amp;rsquo;t understand what the output means and can&amp;rsquo;t evaluate whether the agent&amp;rsquo;s next step is appropriate, you&amp;rsquo;ve handed control of a production incident to something that can&amp;rsquo;t distinguish a victim from a cause. When it&amp;rsquo;s wrong during a live incident, you make things worse. This article builds the mental model that lets you troubleshoot yourself. Use LLMs to learn these concepts on your own time. Don&amp;rsquo;t rely on them at 3am.&lt;/p&gt;
&lt;p&gt;The sequence below is designed to give you understanding before you reach the &amp;ldquo;act&amp;rdquo; step.&lt;/p&gt;
&lt;h2 id="if-you-have-sql-access-to-the-primary"&gt;If you have SQL access to the primary
&lt;/h2&gt;&lt;p&gt;This is the fuller diagnostic path. You can query the system tables directly. What follows assumes you can connect to the primary (or a replica that exposes these views). System tables show what&amp;rsquo;s happening right now; dashboards show what happened over the past hour. Both have a place, and Step 7 covers what to look for in dashboards. Learn whatever monitoring you have before you need it. Figuring out which tab shows wait events at 3am is wasted time.&lt;/p&gt;
&lt;p&gt;The first thing you want to see is the process list, filtered to active queries and ordered by time. On PostgreSQL that&amp;rsquo;s &lt;code&gt;pg_stat_activity&lt;/code&gt;. On MySQL that&amp;rsquo;s &lt;code&gt;SHOW PROCESSLIST&lt;/code&gt; or, better, &lt;code&gt;performance_schema.processlist&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;Before you run anything, protect your own session:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- PostgreSQL
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LOCAL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;statement_timeout&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;5s&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- MySQL (per-query)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="cm"&gt;/*+ MAX_EXECUTION_TIME(5000) */&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&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Why: if the database is under heavy pressure, your diagnostic query competes for the same resources. A five-second timeout means your debugging doesn&amp;rsquo;t pile onto the problem. If your diagnostic can&amp;rsquo;t finish in five seconds, that itself tells you something (extreme contention, buffer pressure, WAL pressure).&lt;/p&gt;
&lt;p&gt;Now pull the active sessions with the columns that actually help you categorize:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- PostgreSQL
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;wait_event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;wait_event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;xact_start&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;xact_duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;query_start&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;query_duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pg_blocking_pids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;blocked_by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &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;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;query_snippet&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;idle&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pg_backend_pid&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;xact_start&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NULLS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LAST&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Most diagnostic snippets you&amp;rsquo;ll find online show &lt;code&gt;pid&lt;/code&gt;, &lt;code&gt;query&lt;/code&gt;, &lt;code&gt;state&lt;/code&gt;, and duration. They skip two columns that matter: &lt;code&gt;wait_event_type&lt;/code&gt; and &lt;code&gt;wait_event&lt;/code&gt;. These tell you &lt;em&gt;why&lt;/em&gt; a query is taking long, not just &lt;em&gt;that&lt;/em&gt; it&amp;rsquo;s taking long.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- MySQL (use performance_schema, not INFORMATION_SCHEMA)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PROCESSLIST_ID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PROCESSLIST_STATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PROCESSLIST_TIME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;duration_sec&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&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;BLOCKING_THREAD_ID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;blocked_by_thread&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &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;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PROCESSLIST_INFO&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;query_snippet&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;performance_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;threads&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LEFT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;performance_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data_lock_waits&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;THREAD_ID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&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;REQUESTING_THREAD_ID&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PROCESSLIST_COMMAND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Sleep&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;TYPE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;FOREGROUND&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PROCESSLIST_TIME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;div class="warning-box"&gt;
 &lt;strong&gt;Warning&lt;/strong&gt;
 &lt;div&gt;On MySQL, use &lt;code&gt;performance_schema.threads&lt;/code&gt; or &lt;code&gt;performance_schema.processlist&lt;/code&gt; (8.0.22+), not &lt;code&gt;INFORMATION_SCHEMA.PROCESSLIST&lt;/code&gt;. &lt;a class="link" href="https://bugs.mysql.com/bug.php?id=94077" target="_blank" rel="noopener"
 &gt;MySQL Bug #94077&lt;/a&gt; (January 2019) documented a 70% performance drop from polling &lt;code&gt;INFORMATION_SCHEMA.PROCESSLIST&lt;/code&gt; under load. &lt;a class="link" href="https://bugs.mysql.com/bug.php?id=100049" target="_blank" rel="noopener"
 &gt;Bug #100049&lt;/a&gt; (June 2020) showed the same query causing pending queries to pile up until the server became unresponsive. Both trace to a mutex held during execution. The diagnostic query you run during an incident should not itself become part of the incident.&lt;/div&gt;
&lt;/div&gt;

&lt;div class="note-box"&gt;
 &lt;strong&gt;Note&lt;/strong&gt;
 &lt;div&gt;Worth asking your DBA or platform team to wrap these queries into views (&lt;code&gt;active_queries&lt;/code&gt;, &lt;code&gt;blocking_chains&lt;/code&gt;, &lt;code&gt;long_transactions&lt;/code&gt;) so that during an incident you&amp;rsquo;re running &lt;code&gt;SELECT * FROM active_queries&lt;/code&gt; instead of assembling joins from memory. You don&amp;rsquo;t want to be copy-pasting SQL from a blog post at 3am.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="step-3-look-for-obvious-offenders"&gt;Step 3: Look for obvious offenders
&lt;/h2&gt;&lt;p&gt;Start with the longest-running queries in the output. Some problems are visible from the query snippet alone. A &lt;code&gt;SELECT *&lt;/code&gt; with no WHERE clause on a large table, a function wrapping a column in the predicate (&lt;a class="link" href="https://explainanalyze.com/p/non-sargable-predicates-how-a-function-in-where-kills-your-index/" &gt;non-SARGable&lt;/a&gt;), a COUNT(*) over millions of rows, an N+1 pattern showing up as dozens of identical queries with different IDs. If you recognize the shape, you already know what to fix.&lt;/p&gt;
&lt;p&gt;Also look for DDL in the list. An &lt;code&gt;ALTER TABLE&lt;/code&gt;, &lt;code&gt;CREATE INDEX&lt;/code&gt;, or &lt;code&gt;DROP INDEX&lt;/code&gt; that&amp;rsquo;s been running longer than you&amp;rsquo;d expect is usually not doing the work it looks like it&amp;rsquo;s doing. It&amp;rsquo;s waiting on a lock. On MySQL this shows up as a metadata lock (MDL): the DDL waits for every transaction still holding the table open to commit or roll back, and meanwhile every new query against the table queues behind the DDL. On PostgreSQL the equivalent is an &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; lock at the relation level, with the same cascade: the DDL waits on active transactions, and everything else waits on the DDL.&lt;/p&gt;
&lt;p&gt;The non-obvious version is when no heavy query is running but the server is wedged anyway. The trigger is usually something innocuous: a connection pooler holding a session &amp;lsquo;idle in transaction&amp;rsquo;, an analytics job that opened a transaction and never closed it, a long SELECT still holding its &lt;code&gt;ACCESS SHARE&lt;/code&gt; lock, or an autovacuum touching the same table. The DDL blocks on whichever of those it is, then every new query against the table queues behind the DDL. The process list shows a wall of waiting sessions against one table, the DDL at the head of the queue, and the actual root cause somewhere further down (or in a connection that doesn&amp;rsquo;t look problematic at all).&lt;/p&gt;
&lt;p&gt;If nothing jumps out from the query text alone, move to Step 4 and Step 5 to dig deeper into the suspect query. If nothing looks slow at all, skip to Step 6.&lt;/p&gt;
&lt;h2 id="step-4-check-the-schema"&gt;Step 4: Check the schema
&lt;/h2&gt;&lt;p&gt;Once you have a suspect query, look at the table it&amp;rsquo;s hitting. Pull the table definition (&lt;code&gt;\d tablename&lt;/code&gt; in psql, &lt;code&gt;SHOW CREATE TABLE tablename&lt;/code&gt; in MySQL) and compare it against what the query needs.&lt;/p&gt;
&lt;p&gt;Walk the clauses one at a time. WHERE columns need indexes the optimizer can actually use, and &amp;lsquo;usable&amp;rsquo; depends on the predicate shape: equality on a high-cardinality column is the straightforward case, range predicates (&lt;code&gt;&amp;gt;&lt;/code&gt;, &lt;code&gt;&amp;lt;&lt;/code&gt;, &lt;code&gt;BETWEEN&lt;/code&gt;) work but constrain what can follow them in a composite, and a large &lt;code&gt;IN (...)&lt;/code&gt; list may flip the planner to a sequential scan even when an index exists. JOIN columns need an index on the inner side of the join (the side being looked up once per outer row); in PostgreSQL, foreign-key columns are not indexed automatically, which is a frequent cause of joins that ran fine at low volume and fell over at scale. ORDER BY can sometimes use an index to skip the sort entirely, but only when the index&amp;rsquo;s leading columns line up with the ORDER BY columns. GROUP BY is the same shape: an index on the grouping columns lets the planner stream the aggregation instead of building a hash, which on a multi-million-row table can be the difference between a sub-second query and one that exhausts &lt;code&gt;work_mem&lt;/code&gt; and spills to disk.&lt;/p&gt;
&lt;p&gt;A missing index on a high-cardinality filter column is the single most common cause of queries that worked fine at low volume and fell over at scale. Composite index column order is the runner-up. An index on &lt;code&gt;(status, created_at)&lt;/code&gt; serves &lt;code&gt;WHERE status = 'pending' ORDER BY created_at&lt;/code&gt;. An index on &lt;code&gt;(created_at, status)&lt;/code&gt; does not, even though it contains the same columns. The general rule is equality columns first, then the range column, then columns used only for sort.&lt;/p&gt;
&lt;p&gt;Also check for &lt;a class="link" href="https://explainanalyze.com/p/covering-index-traps-when-adding-one-column-breaks-your-query/" &gt;covering index gaps&lt;/a&gt;: an index that covered the query last month might have stopped covering it after a column was added to the SELECT list, forcing a heap lookup per row where there used to be an index-only scan.&lt;/p&gt;
&lt;h2 id="step-5-read-the-execution-plan"&gt;Step 5: Read the execution plan
&lt;/h2&gt;&lt;p&gt;If the schema looks right and you still can&amp;rsquo;t explain the behavior, ask the database what it&amp;rsquo;s actually doing. &lt;a class="link" href="https://www.postgresql.org/docs/current/sql-explain.html" target="_blank" rel="noopener"
 &gt;&lt;code&gt;EXPLAIN&lt;/code&gt;&lt;/a&gt; (PostgreSQL) or &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/explain.html" target="_blank" rel="noopener"
 &gt;&lt;code&gt;EXPLAIN&lt;/code&gt;&lt;/a&gt; (MySQL) shows the planner&amp;rsquo;s chosen strategy without executing the query. &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; executes it and shows actual row counts alongside the estimates. Reading these outputs is the most important skill in query troubleshooting. Every claim about what a query &amp;lsquo;should&amp;rsquo; do is wrong until the plan confirms it; the optimizer might pick a different index than you expect, fall back to a sequential scan because of stale statistics, or choose a join order you&amp;rsquo;d never write by hand. The plan is the ground truth.&lt;/p&gt;
&lt;p&gt;Run this on a replica if you have one. The plan will be the same (assuming similar data and stats), and you avoid adding load to a primary that&amp;rsquo;s already under pressure. If you don&amp;rsquo;t have a replica, plain &lt;code&gt;EXPLAIN&lt;/code&gt; (without ANALYZE) gives you the plan without executing the query. It&amp;rsquo;s an estimate, not a measurement, but it&amp;rsquo;s often enough to spot the problem.&lt;/p&gt;
&lt;p&gt;What to look for in the output: sequential scans on large tables (the planner couldn&amp;rsquo;t find a usable index), rows estimated vs. rows actual diverging by orders of magnitude (stale statistics or a bad cardinality guess), reading a million rows to return ten (missing or ignored index), nested loops where each iteration does its own index lookup against a large table (the N+1 shape at the engine level).&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Warning&lt;/strong&gt;
 &lt;div&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; executes the query fully. On a SELECT that takes 30 seconds in production, it will take 30 seconds when you run it too. If the query modifies data (INSERT, UPDATE, DELETE), wrap it in a transaction and roll back: &lt;code&gt;BEGIN; EXPLAIN ANALYZE UPDATE ...; ROLLBACK;&lt;/code&gt;. On a system already under pressure, be deliberate about what you choose to execute.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="step-6-if-nothing-looks-slow-check-volume"&gt;Step 6: If nothing looks slow, check volume
&lt;/h2&gt;&lt;p&gt;Sometimes every query in the process list finishes in a few milliseconds and nothing looks wrong individually. The problem isn&amp;rsquo;t one slow query. It&amp;rsquo;s thousands of fast ones hitting the same resources concurrently.&lt;/p&gt;
&lt;p&gt;This is where digest-level views help. &lt;code&gt;pg_stat_statements&lt;/code&gt; (PostgreSQL) and &lt;code&gt;performance_schema.events_statements_summary_by_digest&lt;/code&gt; (MySQL) aggregate queries by their normalized pattern and track call counts. Sort by &lt;code&gt;total_exec_time&lt;/code&gt; (PostgreSQL) or &lt;code&gt;SUM_TIMER_WAIT&lt;/code&gt; (MySQL), not by mean time. A query that averages 5 ms but fires 10,000 times per second consumes 50 CPU-seconds per wall-second. It will never show up in a &amp;ldquo;longest running&amp;rdquo; list, but it dominates the workload.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;span class="lnt"&gt;20
&lt;/span&gt;&lt;span class="lnt"&gt;21
&lt;/span&gt;&lt;span class="lnt"&gt;22
&lt;/span&gt;&lt;span class="lnt"&gt;23
&lt;/span&gt;&lt;span class="lnt"&gt;24
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- PostgreSQL
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &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;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;query_snippet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total_exec_time&lt;/span&gt;&lt;span class="p"&gt;::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_exec_time&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;avg_ms&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pg_stat_statements&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total_exec_time&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LIMIT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- MySQL (join current statements to their digest summary)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &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;d&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DIGEST_TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;query_snippet&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&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;COUNT_STAR&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;calls&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ROUND&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;SUM_TIMER_WAIT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;e9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total_ms&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ROUND&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;AVG_TIMER_WAIT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="n"&gt;e9&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;avg_ms&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;performance_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;events_statements_current&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;esc&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;performance_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;events_statements_summary_by_digest&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;esc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DIGEST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&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;DIGEST&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;performance_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;threads&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;esc&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;THREAD_ID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;THREAD_ID&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;PROCESSLIST_COMMAND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Sleep&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&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;SUM_TIMER_WAIT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LIMIT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;This is the case from &lt;a class="link" href="https://explainanalyze.com/p/its-almost-always-the-queries-part-i-why-metal-doesnt-help/" &gt;Part I&lt;/a&gt;, where the &lt;code&gt;COUNT(*)&lt;/code&gt; averaged 40 ms and never triggered the slow-query log. Part III walks the CPU-bound version of this in detail.&lt;/p&gt;
&lt;h2 id="step-7-read-the-dashboards"&gt;Step 7: Read the dashboards
&lt;/h2&gt;&lt;p&gt;Dashboards see what the system tables don&amp;rsquo;t: history. By the time you query &lt;code&gt;pg_stat_activity&lt;/code&gt;, you&amp;rsquo;ve lost the picture of what was happening five minutes ago. Whatever you have (Datadog DBM, Aurora and RDS Performance Insights, pganalyze, Grafana with the right exporters), this is where you bring it in. If you have no SQL access at all, the dashboard is your entire diagnostic toolkit; everything you can learn, you&amp;rsquo;ll learn here.&lt;/p&gt;
&lt;p&gt;Start with timing. A graph with a hard step-change at 14:23 is a different problem from one that climbed slowly over the past hour. The hard step points at a discrete event: a deploy, a config change, a single long-running query that started blocking others. The slow climb points at a workload trend or a plan regression that compounded as the working set grew. Overlay deployment markers, autoscaling events, and scheduled job runs on the latency graph if you can. A latency jump that exactly tracks a deploy is the deploy until proven otherwise.&lt;/p&gt;
&lt;p&gt;Active session count is the other timing graph worth pulling. A flat baseline that doubles at 14:23 points at a blocking event. A slow climb over an hour points at workload growth or queue buildup. Session count is harder to lie to than latency: a single slow query can hide in a p99 average, but it can&amp;rsquo;t hide in the count of sessions waiting on it.&lt;/p&gt;
&lt;p&gt;Which resource is pinned tells you which dimension is the cause and which is following. CPU at 100% with IOPS low and stable is a CPU-bound workload, often a missing index causing repeated sorting or hashing, or a regex or JSON predicate doing per-row work the planner can&amp;rsquo;t push down. IOPS pinned with CPU low and waits on &lt;code&gt;IO:DataFileRead&lt;/code&gt; (PostgreSQL) is a buffer-cache miss problem: the working set has outgrown RAM and every query is going to disk. RAM climbing steadily for days while the buffer cache hit rate falls at the same rate is a forecast, not an incident.&lt;/p&gt;
&lt;p&gt;Waits are where dashboards earn their keep. Aurora Performance Insights, pganalyze, and Datadog DBM all stack active sessions by wait class over time, which is exactly the information &lt;code&gt;pg_stat_activity&lt;/code&gt; can&amp;rsquo;t give you historically. Locks dominating means contention: a deadlock storm, or an open long-running transaction blocking everything that touches the same rows. IO dominating means buffer pressure or storage saturation. CPU dominating means the queries themselves are doing more work per call than they used to. Client reads dominating points at a slow consumer: the app isn&amp;rsquo;t reading results fast enough and sessions stack up waiting to send the next page.&lt;/p&gt;
&lt;p&gt;WAL and checkpoint pressure are easy to miss because they don&amp;rsquo;t appear in the active query list. WAL generation rate climbing with no proportional traffic increase points at write amplification: a runaway UPDATE rewriting the same rows, a hot index getting bloated, a trigger writing more than it needs to. Checkpoint duration climbing, or checkpoint frequency increasing, means the system can&amp;rsquo;t keep pace with the write rate. On MySQL the same signal shows up as InnoDB log file utilization approaching its configured size, with checkpoint-related stalls visible in &lt;code&gt;SHOW ENGINE INNODB STATUS&lt;/code&gt;. These often correlate with sudden IOPS spikes, because the checkpoint flush is what saturates the disk, not the workload directly.&lt;/p&gt;
&lt;p&gt;Workload composition completes the picture. Most monitoring breaks queries-per-second down by type. A 10x spike in write QPS with read QPS flat is a different incident from a 10x read spike. Within reads, the ratio of index scans to sequential scans is a leading indicator of plan regression: in PostgreSQL, &lt;code&gt;pg_stat_user_tables.seq_scan&lt;/code&gt; climbing on a table that previously got index scans; in MySQL, &lt;code&gt;Handler_read_rnd_next&lt;/code&gt; rising relative to &lt;code&gt;Handler_read_key&lt;/code&gt;. A jump in read-ahead activity (InnoDB&amp;rsquo;s &lt;code&gt;Innodb_buffer_pool_read_ahead&lt;/code&gt;, Aurora&amp;rsquo;s read-IOPS metrics) often signals large scans that weren&amp;rsquo;t there before: a new query, or an old query whose plan changed.&lt;/p&gt;
&lt;p&gt;You&amp;rsquo;re not trying to find the exact query from the dashboard. The goal is to narrow the category before going back to the system tables: read-side vs write-side, query-level vs workload-level, lock contention vs buffer pressure vs CPU saturation. That tells you where to focus in the earlier steps, and if you have no SQL access, it&amp;rsquo;s enough to escalate with specifics. &amp;ldquo;40 sessions in Lock wait starting at 2:43, no deploy in the window, getting worse&amp;rdquo; is something whoever owns the database can act on. &amp;ldquo;It&amp;rsquo;s slow&amp;rdquo; is not.&lt;/p&gt;
&lt;h2 id="before-the-next-incident"&gt;Before the next incident
&lt;/h2&gt;&lt;p&gt;Everything above assumes you can run the queries when you need them, that you know what their output means, and that you&amp;rsquo;ve seen what &amp;rsquo;normal&amp;rsquo; looks like so the abnormal stands out. None of that is true at 3am unless you&amp;rsquo;ve done the work before then.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Know your access.&lt;/strong&gt; Can you connect to the primary, or only to a replica? Read-only or with permission to call &lt;code&gt;pg_terminate_backend&lt;/code&gt;? Does your role have access to &lt;code&gt;pg_stat_statements&lt;/code&gt; (which requires &lt;code&gt;pg_read_all_stats&lt;/code&gt; on PostgreSQL 13+) and to the MySQL &lt;code&gt;performance_schema&lt;/code&gt; tables? On managed services, some catalog views are hidden behind parameter groups that take a restart to enable. The time to discover you don&amp;rsquo;t have &lt;code&gt;pg_stat_statements&lt;/code&gt; is not the moment you need it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Know your team&amp;rsquo;s views.&lt;/strong&gt; Ops teams often wrap the queries from this article into named views: &lt;code&gt;active_queries&lt;/code&gt;, &lt;code&gt;blocking_chains&lt;/code&gt;, &lt;code&gt;long_transactions&lt;/code&gt;, top-N digest views. Find out whether yours exist. If they do, learn the column names and what they filter out (some hide replication workers, autovacuum, or your own session, which can mislead during an incident if you don&amp;rsquo;t know). If they don&amp;rsquo;t, ask your DBA whether they&amp;rsquo;d take a pull request, or write them yourself. A view you can &lt;code&gt;SELECT * FROM&lt;/code&gt; is faster to run and harder to typo at 3am than a 15-line join assembled from memory.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Run the queries on a quiet system.&lt;/strong&gt; Pull &lt;code&gt;pg_stat_activity&lt;/code&gt; against your dev database while nothing stressful is happening. Note what idle connections look like (the pool&amp;rsquo;s keep-alives, your IDE&amp;rsquo;s introspection queries, your monitoring&amp;rsquo;s polling traffic). Pull a &lt;code&gt;pg_stat_statements&lt;/code&gt; snapshot and read through the top 20. The point is to know what your environment looks like at rest, so during an incident the abnormal jumps out instead of getting lost in baseline noise.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Read the column documentation once.&lt;/strong&gt; &lt;code&gt;wait_event_type&lt;/code&gt; and &lt;code&gt;wait_event&lt;/code&gt; in PostgreSQL have &lt;a class="link" href="https://www.postgresql.org/docs/current/monitoring-stats.html#WAIT-EVENT-TABLE" target="_blank" rel="noopener"
 &gt;a documented enumeration&lt;/a&gt; with dozens of values. MySQL&amp;rsquo;s &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/performance-schema-instrument-naming.html" target="_blank" rel="noopener"
 &gt;performance_schema instruments&lt;/a&gt; follow a naming convention you can learn in fifteen minutes. Knowing that &lt;code&gt;IO:DataFileRead&lt;/code&gt; means a buffer cache miss and &lt;code&gt;Lock:transactionid&lt;/code&gt; means waiting on another transaction&amp;rsquo;s row lock turns opaque output into a diagnosis.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Practice reading EXPLAIN output.&lt;/strong&gt; Pull a slow-ish query from your own codebase and run &lt;code&gt;EXPLAIN (ANALYZE, BUFFERS)&lt;/code&gt; on it in a quiet environment. The &amp;lsquo;what to look for&amp;rsquo; in Step 5 is more useful when you&amp;rsquo;ve spent thirty minutes staring at a plan in low-stakes context first. Visualizers like &lt;a class="link" href="https://explain.depesz.com" target="_blank" rel="noopener"
 &gt;explain.depesz.com&lt;/a&gt; and &lt;a class="link" href="https://explain.dalibo.com" target="_blank" rel="noopener"
 &gt;explain.dalibo.com&lt;/a&gt; help with PostgreSQL output, and MySQL&amp;rsquo;s &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/explain.html#explain-tree-output" target="_blank" rel="noopener"
 &gt;tree-format EXPLAIN&lt;/a&gt; is more readable than the default table format. But none of those substitutes for knowing what each node type means. Read the docs for sequential scan, index scan, bitmap heap scan, nested loop, hash join, and merge join once.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;What this article doesn&amp;#39;t cover&lt;/strong&gt;
 &lt;div&gt;None of the seven steps is comprehensive. The full diagnostic surface (lock-graph reconstruction, planner cost-model tuning, statistics histograms, MVCC and vacuum internals, the catalog views nobody talks about) takes years to learn and a book to lay out. What&amp;rsquo;s here is less than the minimum a DBA assumes you already know - enough to triage with confidence and escalate with specifics, not enough to skip the call to whoever owns the database.&lt;/div&gt;
&lt;/div&gt;
</description></item><item><title>It's Almost Always the Queries, Part I: Why Metal Doesn't Help</title><link>https://explainanalyze.com/p/its-almost-always-the-queries-part-i-why-metal-doesnt-help/</link><pubDate>Sat, 16 May 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/its-almost-always-the-queries-part-i-why-metal-doesnt-help/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post It's Almost Always the Queries, Part I: Why Metal Doesn't Help" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;Infrastructure alerts on a relational database almost always trace to query and schema choices, not capacity. Scaling the box rents the bug. The exceptions are real but smaller than most teams assume.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;What unites replication lag, CPU at 100%, dashboard timeouts, disk filling, and the server that crashes every Tuesday afternoon? Almost always the same thing: bad queries. Throwing metal at it fixes the symptom, leaves the cause, and rents back the same outage at the next traffic threshold.&lt;/p&gt;
&lt;p&gt;A team added a third read replica because the primary was at 95% CPU. Lag got worse, not better. The slow-query log was empty because the threshold was 100 ms and the offending statement averaged 40 ms. &lt;code&gt;pg_stat_statements&lt;/code&gt; sorted by &lt;code&gt;total_exec_time&lt;/code&gt; showed it on the first row: &lt;code&gt;SELECT COUNT(*) FROM orders WHERE status = 'open'&lt;/code&gt;, fired by the status-filter dropdown on the orders page, roughly 600 calls per second at peak. Forty milliseconds becomes 24 CPU-seconds per wall-second the moment a few hundred users land on that page in parallel. The same shape is documented publicly in &lt;a class="link" href="https://github.com/sferik/rails_admin/issues/2699" target="_blank" rel="noopener"
 &gt;Rails Admin issue #2699 from August 2016&lt;/a&gt;, where COUNT(*) on tables with 1-10 million rows ran 10-20 seconds and made the admin dashboard unusable. Part III walks the CPU case in depth; Part II is the troubleshooting playbook that gets you there, and this article is the framing for the whole series.&lt;/p&gt;
&lt;h2 id="the-obvious-fix-and-why-it-buys-you-weeks"&gt;The obvious fix and why it buys you weeks
&lt;/h2&gt;&lt;p&gt;Bigger instance. More replicas. Faster IOPS. Every one of those is a real lever, and on a sufficiently bad day, the right immediate move. They share a property the postmortem usually skips: each rents capacity proportional to the bug&amp;rsquo;s cost, and the bug stays. The 40 ms COUNT(*) costs 60% less on a box twice the size, but the cost is still proportional to traffic, and traffic only goes one direction. Six months later the same team is sizing up the box again, and the dropdown is still firing 600 times a second.&lt;/p&gt;
&lt;p&gt;I know, I know - digging into the query, reading the plan, refactoring the ORM call is engineering time, and engineering time looks more expensive than a bigger instance. On the day of the incident, the math holds. A quarter later it stops holding, when the same dropdown is firing 900 times a second instead of 600 and the bigger box is back at 95%. You are going to deal with it. The only question is whether you spend one SME hour now, or whether you keep paying the surcharge that grows with traffic and end up spending more time on it over the year than the tuning would have cost in the first place.&lt;/p&gt;
&lt;h2 id="four-symptoms-one-cause"&gt;Four symptoms, one cause
&lt;/h2&gt;&lt;p&gt;Almost every infrastructure alert on a relational database has a hardware-shaped reading and a query-shaped reading. The query-shaped reading is right more often. The four symptoms below each get their own post in this series; what follows is the map, not the territory.&lt;/p&gt;
&lt;p&gt;CPU pegged at 100% usually means an aggregate or lookup that runs cheaply per call and fires under heavy concurrency: a dashboard &lt;code&gt;COUNT(*)&lt;/code&gt;, an unread-count badge that re-renders on every page load, an &lt;a class="link" href="https://explainanalyze.com/p/orms-are-a-coupling-not-an-abstraction/" &gt;N+1 from the ORM&lt;/a&gt; inside a list view that nobody noticed. Sort &lt;code&gt;pg_stat_statements&lt;/code&gt; by &lt;code&gt;total_exec_time&lt;/code&gt;, not &lt;code&gt;mean_exec_time&lt;/code&gt;, and the offender is on the first page. MySQL&amp;rsquo;s equivalent is &lt;code&gt;performance_schema.events_statements_summary_by_digest&lt;/code&gt; ordered by &lt;code&gt;SUM_TIMER_WAIT&lt;/code&gt;; same trick, different schema. &lt;strong&gt;Part III.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Memory pressure is rarely a workload that needs more RAM. More often it&amp;rsquo;s &lt;code&gt;work_mem&lt;/code&gt; multiplied by connection count: each sort or hash spilling its allocation, multiplied by a few hundred Rails workers, blows past whatever the instance has. The MySQL shape is the same with per-thread buffers (&lt;code&gt;sort_buffer_size&lt;/code&gt;, &lt;code&gt;join_buffer_size&lt;/code&gt;, &lt;code&gt;tmp_table_size&lt;/code&gt;) multiplied by &lt;code&gt;max_connections&lt;/code&gt;. Bigger box, same multiplier, same alert. &lt;strong&gt;Part IV.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Disk filling and IOPS saturation usually mean bloat (no-op UPDATEs producing dead tuples faster than autovacuum cleans them), audit tables without retention, or &lt;a class="link" href="https://explainanalyze.com/p/non-sargable-predicates-how-a-function-in-where-kills-your-index/" &gt;non-SARGable predicates&lt;/a&gt; and &lt;a class="link" href="https://explainanalyze.com/p/covering-index-traps-when-adding-one-column-breaks-your-query/" &gt;coverage that broke when a column got added to the SELECT&lt;/a&gt;, forcing random heap fetches that look like an IOPS shortfall. InnoDB has the same shape with undo-log growth under long-running transactions starving purge, and with random reads from non-clustered secondary indexes that force a clustered-index lookup per row. Provisioning more IOPS works, and leaves the access pattern intact. &lt;strong&gt;Part V.&lt;/strong&gt;&lt;/p&gt;
&lt;p&gt;Replication lag presents as a replica problem and is almost always a writer problem. Long transactions hold back replay. Over-indexed tables under heavy UPDATE traffic produce write amplification; the same WAL stream replays single-threaded on every replica. ORMs that re-write every column on every save produce no-op WAL records that every replica then applies. MySQL has the same pattern through the binlog, with one SQL thread per replica by default; parallel replication helps on independent workloads but rarely closes the gap on write-heavy ones with intra-transaction dependencies. &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/" &gt;Long-held locks blocking unrelated work&lt;/a&gt; compound the same way. Adding replicas makes it worse, not better. &lt;strong&gt;Part VI.&lt;/strong&gt;&lt;/p&gt;
&lt;h2 id="read-the-top-10-before-opening-the-cloud-console"&gt;Read the top-10 before opening the cloud console
&lt;/h2&gt;&lt;p&gt;The discipline is mechanical. Before touching the instance type, the replica count, or the IOPS budget, pull the top-10 from &lt;code&gt;pg_stat_statements&lt;/code&gt; sorted by &lt;code&gt;total_exec_time&lt;/code&gt; and diff against last week. If the offender is new (a recently shipped feature, a new dashboard tile, an admin tool someone built last quarter) the fix is at that callsite. If the offender has been there the whole time and is only now problematic, traffic crossed a threshold the query couldn&amp;rsquo;t hold. Either way, the action is at the query, not the box.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;The trade-off this advice doesn&amp;#39;t fix&lt;/strong&gt;
 &lt;div&gt;&lt;p&gt;Query tuning is cheap in dollars and expensive in SME hours. A team without a database specialist and with a deadline in two weeks does not have the headcount to read an execution plan, refactor an ORM call, and verify the fix under load. For that team, scaling the box is the right move, and the bug stays on the backlog as planned debt. The article&amp;rsquo;s framing assumes you have, or are willing to develop, the skill to read &lt;code&gt;pg_stat_statements&lt;/code&gt; and &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; output. Without that skill, capacity is what you can buy; query understanding is what you can&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;Asking Claude or another LLM is a real option for narrow questions (&amp;ldquo;what does this &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; mean?&amp;rdquo;, &amp;ldquo;is this index doing what I think?&amp;rdquo;) and worth using as a first pass. It hallucinates more on architecture than on syntax, and the only thing standing between that and a worse outage is whether someone on your team can read what it produced and tell when it&amp;rsquo;s wrong.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="when-this-doesnt-apply"&gt;When this doesn&amp;rsquo;t apply
&lt;/h2&gt;&lt;p&gt;In an early-stage startup where four engineers are doing four jobs each and the storage layer is one of fifteen things on someone&amp;rsquo;s plate, reading &lt;code&gt;pg_stat_statements&lt;/code&gt; weekly is not where the next dollar of engineering time goes. Scale the box. The cloud upsell exists for a reason, and at that stage the bug stays on the backlog as planned debt while the team finds product-market fit. The trade-off is honest as long as someone knows the debt is there.&lt;/p&gt;
&lt;p&gt;The version of this that hurts later is a data-heavy company building without anyone who owns the storage layer. If the product is fundamentally about reading and writing data (OLTP-heavy SaaS, analytics-adjacent dashboards, event ingestion at volume, anything that touches embeddings or vector search), the schema and access patterns chosen in the first six months decide what is available to build on for the next three years. Without an SME on the foundation, the team ships a model the workload can&amp;rsquo;t actually run, and the same query-shaped failures arrive on a much shorter timeline than the founders planned for. The cheap version of doing this right is hiring or contracting someone who has seen this fail before, before the schema is hardened by code that depends on it.&lt;/p&gt;
&lt;h2 id="what-the-next-five-parts-cover"&gt;What the next five parts cover
&lt;/h2&gt;&lt;p&gt;Part II is the troubleshooting playbook: what to open first when an alert fires, the built-in views worth knowing (&lt;code&gt;pg_stat_activity&lt;/code&gt;, &lt;code&gt;pg_stat_statements&lt;/code&gt;, &lt;code&gt;pg_locks&lt;/code&gt; on Postgres; &lt;code&gt;performance_schema.threads&lt;/code&gt;, &lt;code&gt;data_locks&lt;/code&gt;, &lt;code&gt;events_statements_summary_by_digest&lt;/code&gt; on MySQL), the handful of custom views worth saving for the next incident, and when a third-party tool like pganalyze, PMM, or Datadog DBM earns its cost. Part III takes the CPU case in detail: why sorting by &lt;code&gt;total_exec_time&lt;/code&gt; finds the offender that &lt;code&gt;mean_exec_time&lt;/code&gt; hides, and how MVCC visibility makes unbounded &lt;code&gt;COUNT(*)&lt;/code&gt; the canonical example. Part IV is memory pressure and &lt;code&gt;work_mem&lt;/code&gt; math. Part V is disk and IOPS: bloat, retention, fillfactor, the access patterns that look like a storage shortfall. Part VI is replication lag, where the fix is always on the writer. Each post stands on its own; reading them in order makes the pattern visible.&lt;/p&gt;</description></item><item><title>Exposing Data to an Agent: MCP vs API</title><link>https://explainanalyze.com/p/exposing-data-to-an-agent-mcp-vs-api/</link><pubDate>Fri, 15 May 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/exposing-data-to-an-agent-mcp-vs-api/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Exposing Data to an Agent: MCP vs API" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;MCP is a wire protocol; what sits behind it decides the blast radius. In non-prod, pointing it at the database tends to be fine, because unbounded exploration is worth more than the occasional mistake. In prod, the shape that holds up is having the MCP server&amp;rsquo;s tools call an agent-specific API that enforces allowlisted operations, row caps, column masking, and per-prompt audit, rather than the database directly. The version that points at the database tends to surface later as a privacy incident.&lt;/div&gt;
&lt;/div&gt;

&lt;div class="note-box"&gt;
 &lt;strong&gt;Note&lt;/strong&gt;
 &lt;div&gt;This is about the third-party database MCP servers from public registries (Postgres, MySQL, MongoDB, Redis, Elasticsearch), whose load-bearing tool is &lt;code&gt;query(sql_string)&lt;/code&gt; against whatever connection they were configured with. A custom MCP server you wrote to wrap your own API is a different shape and isn&amp;rsquo;t the argument here.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;A revenue dashboard agent runs against production through the MCP server the analytics team stood up last quarter. Marketing asks for enterprise signups in Q1 with their account contacts. The agent generates &lt;code&gt;SELECT id, email, phone, last_login_at, plan, mrr FROM users JOIN subscriptions ... WHERE created_at &amp;gt;= '2026-01-01' AND plan = 'enterprise'&lt;/code&gt;, and 2.3M rows come back. The agent truncates the chat-side display to the first fifty. The full result set leaves the database, crosses the MCP server, and lands in the conversation history the model provider keeps for thirty days. The connection that ran the SELECT held a slot on the read replica for fourteen minutes before the proxy reaped it, and p99 read latency for the customer-facing dashboard tripled over that window. The audit log records one MCP call from &lt;code&gt;mcp-readonly@analytics&lt;/code&gt;. No prompt, no agent identity, no user attribution. The post-mortem has six unanswered questions.&lt;/p&gt;
&lt;h2 id="read-only-doesnt-bound-any-of-this"&gt;Read-only doesn&amp;rsquo;t bound any of this
&lt;/h2&gt;&lt;p&gt;The patch the post-mortem will land on in fifteen minutes is &amp;ldquo;make the MCP connection read-only.&amp;rdquo; The connection already was. Read-only restricts the verb set, and every failure above happened on &lt;code&gt;SELECT&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;A read-only SELECT against a 50M-row table is still a SELECT, with the same cost on the replica. Read access on &lt;code&gt;users&lt;/code&gt; is read access on &lt;code&gt;users.password_hash&lt;/code&gt; and &lt;code&gt;users.api_token&lt;/code&gt;. The corruption floor that &lt;a class="link" href="https://explainanalyze.com/p/if-your-guardrail-is-a-prompt-you-dont-have-a-guardrail/" &gt;If Your Guardrail Is a Prompt&lt;/a&gt; describes eventually emits a query against a table the agent had no business touching, and read-only lets it through. And every row the agent reads becomes part of the context window the model provider keeps for thirty days, regardless of what your privacy policy says.&lt;/p&gt;
&lt;p&gt;The verb was never the surface. The catalog is.&lt;/p&gt;
&lt;h2 id="mcp-is-the-wire-the-endpoint-is-the-policy"&gt;MCP is the wire, the endpoint is the policy
&lt;/h2&gt;&lt;p&gt;MCP is a tool-surface protocol. The standard database MCP server exposes &lt;code&gt;query(sql_string)&lt;/code&gt;: the model writes SQL, the server forwards it to whatever connection it was configured with. That makes the MCP server a conduit between the model and the catalog. The agent&amp;rsquo;s effective permissions are the connection&amp;rsquo;s, the agent&amp;rsquo;s query surface is every SQL statement the connection can run, and the audit trail is one row per call from one identity with the SQL as the only payload, which &lt;a class="link" href="https://explainanalyze.com/p/if-your-guardrail-is-a-prompt-you-dont-have-a-guardrail/" &gt;a pre-AI audit log treated as sufficient and an AI-era audit log doesn&amp;rsquo;t&lt;/a&gt;.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;Note&lt;/strong&gt;
 &lt;div&gt;The protocol isn&amp;rsquo;t the problem. MCP solves a real coordination problem: how a model discovers and calls tools across hosts, harnesses, and vendors. What you put on the other end is the part that decides whether you&amp;rsquo;ve exposed a database or an API.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;A SQL conduit also makes the silent-failure shapes from &lt;a class="link" href="https://explainanalyze.com/p/what-ai-gets-wrong-about-your-database/" &gt;What AI Gets Wrong About Your Database&lt;/a&gt; reachable from a chat window: JOIN paths against tables the model inferred from names, &lt;code&gt;status = 1&lt;/code&gt; filters where &lt;code&gt;1&lt;/code&gt; means &amp;ldquo;pending&amp;rdquo; not &amp;ldquo;active&amp;rdquo;, unconstrained bridge tables that multiply rows. None of it requires write access, and all of it lands in the model provider&amp;rsquo;s trace.&lt;/p&gt;
&lt;p&gt;The thing you want on the other end of MCP is an API. Not your customer-facing API. An API written for the agent: a list of operations it can call, with parameters, shaped responses, per-operation entitlements, row caps, timeouts, column masking, and an audit trail that records the agent identity and the prompt that produced the call. The agent never composes SQL. It calls &lt;code&gt;get_enterprise_signups(quarter, plan)&lt;/code&gt; and gets back an aggregated result.&lt;/p&gt;
&lt;h2 id="what-the-agent-api-looks-like"&gt;What the agent API looks like
&lt;/h2&gt;&lt;p&gt;&lt;strong&gt;Named operations, not raw SQL.&lt;/strong&gt; &lt;code&gt;get_revenue_by_segment(quarter, segment)&lt;/code&gt;, &lt;code&gt;list_active_enterprise_accounts(limit, cursor)&lt;/code&gt;, &lt;code&gt;get_customer_summary(customer_id)&lt;/code&gt;. The agent picks from a menu the platform team curated. Operations get added when an analysis pattern proves useful enough to commit to a stable interface.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Responses shaped for the agent, not for the application.&lt;/strong&gt; A revenue-by-segment call returns aggregated totals, not the 2.3M rows behind them. The shape is token-budget aware: a top-N list with totals beats a paged row dump.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Column-level masking inside the API.&lt;/strong&gt; Email becomes a domain plus a hash. Account IDs are opaque tokens the API resolves on the next call, not database primary keys. Sensitive columns are gated by per-operation entitlements granted explicitly to the agent identity.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Row caps and statement timeouts the API enforces.&lt;/strong&gt; Every operation has a hard cap on rows and database time. Caps live in code the API team owns, not in the prompt. If an operation needs higher caps, the cap is raised for that operation, not the connection.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Per-call audit with prompt provenance.&lt;/strong&gt; Every call records the agent identity, upstream user, operation, parameters, response shape, row counts, latency, and the prompt that produced the call. Six months later, &amp;ldquo;who ran the query that leaked the enterprise customer list&amp;rdquo; is two &lt;code&gt;SELECT&lt;/code&gt;s away.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Per-agent rate limits.&lt;/strong&gt; Agents loop. Agents retry. The API budgets calls per identity, per operation, and per database time. The budget is a backstop on cost, on the replica, and on the model provider&amp;rsquo;s trace volume.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Warning&lt;/strong&gt;
 &lt;div&gt;Don&amp;rsquo;t reuse your customer-facing API for this. Your customer API is shaped for an authenticated user reading their own data. The agent API is shaped for a service account reading across users, returning aggregates rather than rows, masking PII by default, and logging every call against a prompt. Two consumers, two contracts. One API that tries to serve both ends up either too permissive for customers or too restrictive for agents.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The MCP server&amp;rsquo;s tools then become thin wrappers over the API. Each MCP tool corresponds to one API operation. The agent sees &lt;code&gt;get_revenue_by_segment&lt;/code&gt; as a tool; under the hood it&amp;rsquo;s an HTTP call to a service that talks to the database with its own pool, its own identity, and its own rules. The model never speaks SQL to anything.&lt;/p&gt;
&lt;h2 id="what-you-get-for-the-work"&gt;What you get for the work
&lt;/h2&gt;&lt;p&gt;Control over what&amp;rsquo;s exposed, including the catalog. The API is the curated surface; what isn&amp;rsquo;t on the surface isn&amp;rsquo;t reachable. PII is masked or omitted by default, sensitive tables don&amp;rsquo;t have an operation, and the system catalog (&lt;code&gt;information_schema&lt;/code&gt;, &lt;code&gt;pg_catalog&lt;/code&gt;, MongoDB&amp;rsquo;s &lt;code&gt;listCollections&lt;/code&gt;) never reaches the agent. Hide the catalog and you hide the menu of mistakes the model can make. The same surface-narrowing pays a partial dividend on prompt injection: an instruction smuggled into a document the agent reads has no &lt;code&gt;query(sql)&lt;/code&gt; tool to hijack, only the operations on the menu.&lt;/p&gt;
&lt;p&gt;Observability. Who called, when, with what parameters, against what prompt, returning what row counts. You can see which agents are over-fetching, which operations are getting hammered, which prompts produce weird call patterns. Patterns drive the next iteration: the operation called twenty times an hour gets cached, the one that always returns a million rows gets a tighter cap.&lt;/p&gt;
&lt;p&gt;Throttling in a layer the database doesn&amp;rsquo;t reach. Per-agent, per-operation, per-minute, with hard backpressure during a customer-facing incident. This matters most when the agent is pointed at a primary: it shares a connection pool and CPU budget with the customer-facing write path, and a runaway loop or deep aggregation can move primary CPU enough to slow checkout. Statement timeouts on the database alone don&amp;rsquo;t help, because most of the damage lands in the first ten seconds. The API can apply the throttle at the call boundary, before the SQL reaches the connection: per-agent QPS caps, per-operation concurrency limits, a circuit breaker on customer-facing latency.&lt;/p&gt;
&lt;h2 id="where-mcp-direct-still-earns-its-keep"&gt;Where MCP-direct still earns its keep
&lt;/h2&gt;&lt;p&gt;Local development against a seeded test database. Nightly-refreshed sanitized snapshots of production with PII stripped. CI integration tests against ephemeral databases built from fixtures. Single-operator setups where the agent&amp;rsquo;s permissions are explicitly the operator&amp;rsquo;s. In all four, the cost of a mistake is bounded, and the loop of asking any question and throwing the answer away is the point of the environment. Patterns that prove useful in dev or snapshots get promoted to operations on the prod API; the rest stay in dev.&lt;/p&gt;
&lt;p&gt;The dividing line is who pays the cost of a mistake. If it&amp;rsquo;s the same person running the agent, MCP-direct is fine. If it&amp;rsquo;s a customer whose contact list just got absorbed into a model provider&amp;rsquo;s training-eligible context buffer, MCP through the API. A two-engineer team with one agent and one use case can defer the API, but they&amp;rsquo;ll feel the cost the first time a second agent shows up or the first time a privacy review asks where customer data has been read from.&lt;/p&gt;
&lt;h2 id="if-mcp-direct-harden-the-database-side"&gt;If MCP-direct, harden the database side
&lt;/h2&gt;&lt;p&gt;When the team picks MCP-direct in prod anyway, the database layer has knobs worth turning on. None substitute for an API. All are cheap.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A dedicated database user for the MCP connection.&lt;/strong&gt; Not the analytics role, not an existing service account, not anything with grants accumulated over years. The agent&amp;rsquo;s user gets its own grants and an audit-log identity that names a single purpose.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Per-schema and per-table grants.&lt;/strong&gt; PostgreSQL&amp;rsquo;s &lt;code&gt;REVOKE ALL ON SCHEMA ... FROM PUBLIC&lt;/code&gt; is the underused default. The agent&amp;rsquo;s role gets read on a small set of schemas (often a dedicated &lt;code&gt;analytics&lt;/code&gt; schema of shaped views), with explicit denies on schemas holding credentials, secrets, audit logs, and the system catalog.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Column-level masking via views or row-level security.&lt;/strong&gt; A view over &lt;code&gt;users&lt;/code&gt; that hashes email and omits &lt;code&gt;password_hash&lt;/code&gt;, &lt;code&gt;api_token&lt;/code&gt;, and &lt;code&gt;phone&lt;/code&gt; closes most PII exfiltration in five minutes. RLS policies on tenant-scoped tables enforce a single-tenant read by default.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Aggressive statement timeouts and connection caps.&lt;/strong&gt; &lt;code&gt;statement_timeout&lt;/code&gt; and &lt;code&gt;idle_in_transaction_session_timeout&lt;/code&gt; set per role at five or ten seconds kill runaway aggregations before they touch replica CPU. Connection caps via PgBouncer prevent the agent from monopolizing the pool during a retry storm.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;The pattern is the one every public-facing system already settled into a decade ago: you don&amp;rsquo;t expose the database to the internet, you put an API in front. The agent is a new principal that deserves the same treatment. MCP is the transport, the way HTTP is the transport for your frontend. Transports don&amp;rsquo;t make policy. Pointing MCP at a database makes the database the endpoint, and the database has no concept of an agent identity, a prompt, or a column-level mask for a non-human caller.&lt;/p&gt;
&lt;p&gt;Building the agent API is the ideal case of an &lt;a class="link" href="https://explainanalyze.com/p/the-10x-is-real-on-internal-tools-youd-otherwise-never-ship/" &gt;internal tool an AI agent can write quickly&lt;/a&gt;: greenfield code, one team owning the contract, low blast radius, replaceable v1, sandbox available for the first cut. A day or two with a coding agent rather than the quarter-long platform initiative it would have been in 2022. It&amp;rsquo;s testable, observable, and the thing that lets you point MCP at production without filing a privacy incident the following Tuesday.&lt;/p&gt;</description></item><item><title>The 10x Is Real, on Internal Tools You'd Otherwise Never Ship</title><link>https://explainanalyze.com/p/the-10x-is-real-on-internal-tools-youd-otherwise-never-ship/</link><pubDate>Wed, 13 May 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/the-10x-is-real-on-internal-tools-youd-otherwise-never-ship/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post The 10x Is Real, on Internal Tools You'd Otherwise Never Ship" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;AI coding agents do hit 10x or better on a specific slice of work: greenfield internal tools where the agent authors the code and the conventions it later re-reads. Outside that envelope (existing services, cross-team consumers, regulated code paths) the gain compresses to 10–30% at best and turns net negative on mature codebases, because verification cost dominates typing cost.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;A DBRE has had a MySQL binlog purge script in the back of their head for six months. Current process: every other Friday, run &lt;code&gt;SHOW REPLICA STATUS&lt;/code&gt; against each replica to find the oldest binlog any of them is still reading from, take the minimum source-log-file across the fleet, factor in any replica that is lagging, then connect to the primary and run &lt;code&gt;mysql -e &amp;quot;PURGE BINARY LOGS TO 'mysql-bin.XXXXXX'&amp;quot;&lt;/code&gt; with a safe cutoff. By hand, in a notebook, while making sure no replica is far enough behind that the cutoff would yank a binlog the replica still needs and break replication. Pre-AI estimate to script it properly: half a day, with the replica-position scan across the fleet, the safe-cutoff math, a dry-run mode, and the README so the next on-call knows what it does. Never made the sprint. Friday afternoon with a coding agent: a small Go binary that walks each replica, parses &lt;code&gt;SHOW REPLICA STATUS&lt;/code&gt;, computes the minimum source-log-file with a configurable safety margin, refuses to act if any replica is more than 30 seconds behind, runs &lt;code&gt;PURGE BINARY LOGS TO&lt;/code&gt; on the primary, supports &lt;code&gt;--dry-run&lt;/code&gt;, emits structured logs, ships with a README the agent wrote in the same pass, and has a one-shot CI job that exercises it against a sandbox primary plus replica. Forty minutes including the test. Ships Monday. The twenty minutes a week of toil it removes is the kind of work that has never made anyone&amp;rsquo;s quarterly goals.&lt;/p&gt;
&lt;h2 id="where-the-multiplier-comes-from"&gt;Where the multiplier comes from
&lt;/h2&gt;&lt;p&gt;&amp;ldquo;AI is faster everywhere now&amp;rdquo; is the reflexive read of the scenario above, and it&amp;rsquo;s the wrong read. The same agent on the customer-facing payments service, modifying a checkout flow three years old with four other teams reading the code, lands at 10–20% faster on a good day and net negative on a bad one. The numbers in the literature back this up.&lt;/p&gt;
&lt;p&gt;Stanford&amp;rsquo;s &lt;a class="link" href="https://softwareengineeringproductivity.stanford.edu/" target="_blank" rel="noopener"
 &gt;Software Engineering Productivity research&lt;/a&gt; on a corpus of more than 100,000 developers found AI gains of roughly 30 to 35 percent on low-complexity greenfield tasks, 10 to 15 percent on high-complexity greenfield, and brownfield work compressing further from there. Google&amp;rsquo;s &lt;a class="link" href="https://dora.dev/dora-report-2025/" target="_blank" rel="noopener"
 &gt;2025 DORA State of AI-Assisted Software Development report&lt;/a&gt; found 90% of developers using AI and over 80% reporting it made them more productive, while organizational software delivery metrics stayed flat. Individual perceived productivity is not translating into faster delivery to customers. DORA&amp;rsquo;s authors describe AI as an amplifier of existing engineering capability and flag a negative relationship between AI adoption and software delivery stability, with 30% of developers reporting little or no trust in the code AI generates. The verification tax is real: time saved on creation is getting spent on audit.&lt;/p&gt;
&lt;p&gt;METR&amp;rsquo;s &lt;a class="link" href="https://metr.org/blog/2025-07-10-early-2025-ai-experienced-os-dev-study/" target="_blank" rel="noopener"
 &gt;July 2025 controlled study&lt;/a&gt; on sixteen experienced open-source developers in repositories averaging a million-plus lines of code and a decade old found something stranger. The developers were 19% slower with AI than without, and they thought they were 20% faster. Mature codebases are an antagonistic environment for an agent and a confusing one for the human paired with it.&lt;/p&gt;
&lt;p&gt;&amp;ldquo;AI&amp;rdquo; by itself doesn&amp;rsquo;t earn the 10x. Four other conditions have to stack on top of it: greenfield, AI-authored conventions, small audience, fast feedback. Remove one and the math compresses to single-digit percentages. Remove two and the agent is net negative.&lt;/p&gt;
&lt;h2 id="greenfield-is-cheap-context"&gt;Greenfield is cheap context
&lt;/h2&gt;&lt;p&gt;An existing codebase has conventions the agent has to infer from reading. The error-handling pattern, the test-fixture convention, the logger wrapper everyone uses, the half-deprecated config layer the new code is supposed to use instead. Some of this is written down in a CONTRIBUTING file from 2022 that is now wrong in two places. Most of it lives in tribal knowledge. The agent reads twelve files and guesses, sometimes wrongly, and the engineer&amp;rsquo;s time goes into correcting the guess. The context tax is real and it doesn&amp;rsquo;t show up on any productivity dashboard.&lt;/p&gt;
&lt;p&gt;Greenfield collapses that tax to zero. The agent writes the first file and decides the convention, because there is no prior convention to defer to. Error handling is whatever the first handler returned. Logging is whatever the agent picked in the first module. The pattern propagates forward because the agent is reading its own work on every subsequent call. The convention isn&amp;rsquo;t reliable in the LLM sense (nothing the model does is reliable), but the distribution narrows considerably when the only style the agent has seen in this repo is the style it wrote yesterday. Less to hallucinate about. Fewer plausible alternatives competing for the next token.&lt;/p&gt;
&lt;h2 id="the-agent-reads-its-own-code-and-stays-in-pattern"&gt;The agent reads its own code and stays in pattern
&lt;/h2&gt;&lt;p&gt;The cleaner version of this story is that the agent authors both the code and the docs and re-reads its own docs on the next session, so everything stays coherent. That&amp;rsquo;s wishful. Agents leave READMEs stale routinely. A session edits the code, claims success, and silently leaves the documentation pointing at a function signature that&amp;rsquo;s been renamed since. Pretending otherwise is the same &amp;ldquo;AI is reliable now&amp;rdquo; framing that fails the moment someone trusts it.&lt;/p&gt;
&lt;p&gt;The real driver is smaller and more mechanical. On a small greenfield repo, the agent&amp;rsquo;s first move on a new session is usually to scan the files that are already there. The code it reads is code the agent wrote last week. The patterns it produces in this session mirror what it finds in the existing files, because the model is doing what models do: sampling tokens that match the recent style it&amp;rsquo;s already seen in the context. Error handling looks the same in module five as it did in module one because the agent read module one before writing module five. Logging conventions, naming, test layout: all of it propagates from re-reading the code, not from a doc the agent has any particular discipline about.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Warning&lt;/strong&gt;
 &lt;div&gt;Treat any AI-authored README as a build artifact, not a maintained source of truth. Agents skip README updates routinely, and a stale doc is worse than no doc because the next session will follow the wrong instruction confidently. If docs are sticking around, regenerate them rather than maintain them, and don&amp;rsquo;t trust any README older than the most recent code change. The code is the source of truth. The moment the tool grows to where re-reading the code on each session stops being cheap, it has graduated out of this regime and needs the discipline of any other production system.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The shape of the speedup is the same as why a single author writes a coherent short piece faster than three co-authors with the same word count. Coordination overhead is the multiplier, not raw output speed. In a human team, coordination is meetings, code review, RFC documents, the slow accumulation of shared style. In a single-agent greenfield repo, coordination is one session re-reading the small artifact it wrote yesterday and matching the patterns it finds. The cost of &amp;ldquo;what&amp;rsquo;s our convention here&amp;rdquo; is the cost of one repo scan.&lt;/p&gt;
&lt;h2 id="small-audience-fast-deploy-throwaway"&gt;Small audience, fast deploy, throwaway
&lt;/h2&gt;&lt;p&gt;The MySQL binlog organizer is used by one team. The AZ backup shipper is used by the storage on-call. The diagnostic API in front of Redis is curl&amp;rsquo;d by whoever is debugging tonight. None of these tools have customer SLOs, escalation paths, or a third-party integration to coordinate. v1 broken on Monday is fixed by Tuesday afternoon. The cost of a bug is bounded by how loudly someone says &amp;ldquo;this broke&amp;rdquo; in Slack.&lt;/p&gt;
&lt;p&gt;Fast feedback is what makes the testing strategy work. A &lt;code&gt;--dry-run&lt;/code&gt; flag and a sandbox replica is sufficient verification for the binlog purge script, because the worst case is the script crashes and the on-call reverts to the manual &lt;code&gt;find&lt;/code&gt; command, which is what they were already doing. There&amp;rsquo;s no need for a 2000-row test suite. There&amp;rsquo;s barely a need for a runbook. The tool exists in the negative space between &amp;ldquo;the manual process&amp;rdquo; and &amp;ldquo;a properly engineered service&amp;rdquo; and both ends of that range are operationally fine.&lt;/p&gt;
&lt;p&gt;And the tools are throwaway. If the binlog organizer turns out to be shaped wrong (the team wanted archival, the agent built categorization, the bucketing scheme is awkward) it gets thrown out and rewritten from scratch. Sunk cost is hours, not weeks. The agent doesn&amp;rsquo;t carry the same emotional attachment to the previous version&amp;rsquo;s design that a human author would, which makes the rewrite cheaper than the original. That&amp;rsquo;s the property that lets a platform team take more shots than they otherwise would. Most of the toil-removal scripts that sit in the backlog die there because the expected effort feels higher than the expected payoff. The 10x reframes the expected effort, and a noticeable chunk of the backlog suddenly clears its own bar.&lt;/p&gt;
&lt;p&gt;The rewrite property has a second face: the replacement is often an upgrade, not just a regeneration. Most platform teams have the internal HTML dashboard from 2012 nobody wants to touch. Bootstrap, jQuery, server-rendered templates, no input validation on any of the forms, no real documentation, and one engineer left who remembers which buttons do what. Pre-AI estimate to modernize: a quarter that nobody schedules. Friday afternoon with a coding agent: a React frontend with form validation built in, a typed backend behind it, a README that documents what the endpoints actually do, and the input-validation gap that has been a low-grade footgun for three years closed by default, because the new stack treats validation as table stakes. The rewrite is cheaper than the original and an upgrade at the same time, because the agent is writing in a stack that already has the properties the old code lacked.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;Note&lt;/strong&gt;
 &lt;div&gt;The other property internal-only buys you: v2 ships side by side with v1. Both links go on the wiki, the team tries the new one in real work for a week, parity gets confirmed against the old one for the handful of operations anyone actually uses, and v1 gets deleted when nobody opens it anymore. No cutover plan, no customer comms, no parallel-infrastructure cost worth pricing. The team migrates itself. Try this rollout shape with a customer-facing service and the conversation goes very differently.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="how-to-operationalize-this-without-standardizing-it-to-death"&gt;How to operationalize this without standardizing it to death
&lt;/h2&gt;&lt;p&gt;The temptation, as soon as the team notices the multiplier, is to standardize. Pick a Go scaffolding template, pick a logging library, mandate a directory layout, route everything through the platform team for review. Resist that. The standardization is where the multiplier collapses, because the moment two teams have to negotiate conventions, the agent is back in coordination-cost territory.&lt;/p&gt;
&lt;p&gt;A workable shape: every team picks two or three toil-removers from their own backlog and commits to shipping them this quarter. Each tool is owned by one team. Code review is one teammate, same-day, with the explicit understanding that the review is checking the tool does what the README says and doesn&amp;rsquo;t delete prod data, not enforcing a corporate style guide. Testing is on a sandbox or staging instance, not in CI for two weeks. A Backstage plugin backend that surfaces deploy status for the storage team&amp;rsquo;s services lives in the storage team&amp;rsquo;s repo, with the storage team&amp;rsquo;s conventions, and gets reused by other teams only if the other teams want to depend on it (and accept the version it ships in).&lt;/p&gt;
&lt;p&gt;The honest trade-off: tools built this way are good enough for one team and not good enough for the platform catalog. Some will need rewriting if they cross teams. That&amp;rsquo;s fine. The cost of rewriting is hours, and most of them will never cross teams anyway. The cost of insisting on cross-team-grade quality up front is that the diagnostic API the network team would have shipped in a Friday afternoon turns into a Q3 platform initiative, then a Q4 platform initiative, then nothing.&lt;/p&gt;
&lt;p&gt;The same multiplier extends past engineering. Marketing wants to bulk-edit campaign tags. Customer success wants a dashboard joining ticket history against feature adoption. Finance wants a quarterly close helper. With a coding agent, the team that actually knows what shape they want can build the first version themselves. Engineering takes the tool over and hardens it only if it proves valuable enough to graduate. Anything that doesn&amp;rsquo;t prove itself disappears quietly, which is the right outcome for a prototype.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Warning&lt;/strong&gt;
 &lt;div&gt;The sandbox is non-negotiable when non-engineers build their own internal tools. The environment has to be one where the tool cannot reach production credentials, cannot read customer data at production scale, and cannot expose anything to the public internet. A finance analyst prototyping a close helper against the prod database with their own AI agent is the worst-case version of this trend. The same prototype against pseudonymized data in a network-isolated environment is the version that pays off. What the platform team owes the business isn&amp;rsquo;t a scaffolding template. It&amp;rsquo;s a safe place to ship in. The infrastructure cost of getting the sandbox right is small. The cost of getting it wrong is the breach.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="when-the-math-runs-the-other-way"&gt;When the math runs the other way
&lt;/h2&gt;&lt;p&gt;The regime above doesn&amp;rsquo;t extend to every tool a platform team might build. The decision matrix:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Cross-team tools from day one.&lt;/strong&gt; Two teams on the same internal tool means convention negotiation, which means coordination cost, which means the multiplier is gone. Build it the way you build any shared service: design review, versioned API, deprecation policy. The agent is still useful here. It is not 10x useful.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Regulated internal systems.&lt;/strong&gt; HR, finance close, anything in SOX, SOC2, or HIPAA scope. The verification bar rises sharply because the audit trail has to survive an external reviewer, and AI-speed advantage compresses against the human review time the controls require.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tools that touch customer data, even internally.&lt;/strong&gt; A script that joins across &lt;code&gt;users&lt;/code&gt; and &lt;code&gt;subscriptions&lt;/code&gt; is a customer-facing risk regardless of who runs it. Read access to PII is read access to PII whether the caller is a customer-facing API, an analytics agent, or a backup shipper. Blast-radius arguments don&amp;rsquo;t get a discount for being operational.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Destructive operations on prod infra.&lt;/strong&gt; A binlog purge script can break replication if it computes the cutoff wrong on a replica that&amp;rsquo;s silently behind. A snapshot shipper writes to S3 buckets other systems read from. The testing rigor required to ship destructive code pulls the productivity gain down. Still positive, often still 3x or 4x, but not the 10x of a pure read-only diagnostic.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tools that drift into load-bearing.&lt;/strong&gt; The orchestrator that started life as a Friday-afternoon &amp;ldquo;drain, snapshot, upgrade, verify, restore traffic&amp;rdquo; script and is now the deploy path for six services. Once a tool crosses that line it has graduated out of the throwaway regime and into the production-systems regime. The conventions need writing down, the test suite needs to exist, and the next human who edits the code needs to be able to read it without an agent&amp;rsquo;s help. The productivity question is no longer &amp;ldquo;how fast can the agent ship v1&amp;rdquo; but &amp;ldquo;how cheap is the system to operate at year three.&amp;rdquo; Different question. Different answer.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;The 10x envelope is narrow: greenfield, internal, replaceable, one team, fast feedback. It contains the toil backlog that has cost the team twenty silent minutes a week per uncreated tool for years, and the cost of writing those tools just dropped by an order of magnitude. The business case runs higher: when a non-engineer ships a tool with a coding agent, the multiplier is closer to 100x, because the pre-AI version of the tool doesn&amp;rsquo;t exist. Nobody ever wrote a ticket for it.&lt;/p&gt;
&lt;p&gt;The most ambitious version of the regime is the agent API in front of the database: greenfield, owned by one team, conventions the agent sets, audience small enough that v1 wrong is recoverable. The team that has been deferring it because it sounds like a quarter of work has a path to ship it in days.&lt;/p&gt;
&lt;p&gt;What doesn&amp;rsquo;t extend: customer-facing systems and customer data don&amp;rsquo;t tolerate the same speed. Once PII leaks into a model provider&amp;rsquo;s buffer, or a destructive script hits the wrong replica, you don&amp;rsquo;t get it back. The apology you&amp;rsquo;ll get is some flavor of &amp;ldquo;you&amp;rsquo;re absolutely right&amp;rdquo;. The phrase has no recall semantics.&lt;/p&gt;</description></item><item><title>Why You Should Use an Agent to Assist with Quarterly Reviews</title><link>https://explainanalyze.com/p/why-you-should-use-an-agent-to-assist-with-quarterly-reviews/</link><pubDate>Wed, 13 May 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/why-you-should-use-an-agent-to-assist-with-quarterly-reviews/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Why You Should Use an Agent to Assist with Quarterly Reviews" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;Pulling six months of evidence from Jira, GitHub, Slack, and 1:1 notes is mechanical work an agent can do in an hour. Making the judgment calls (rating, promotion, raise, PIP, fire) stays with the manager and is the part that should consume the saved hours. Done with verification (every claim traced to source, no decision delegated) the bookkeeping that used to take a week takes a day, and the team&amp;rsquo;s tracking hygiene improves as a side effect because the agent only sees what&amp;rsquo;s in the system. Done without verification, the failure mode is a confidently wrong review of a real person.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;&lt;a class="link" href="https://lethain.com/performance-compensation-process-exec/" target="_blank" rel="noopener"
 &gt;Will Larson&amp;rsquo;s writeup&lt;/a&gt; of a typical performance cycle at scale puts calibration at three to five hours per round per participating manager, across three rounds (sub-organization, organization, executive). Calibration is the part where managers argue ratings against each other under a budget. It is not the part where the review gets written. The writing happens earlier: pulling six months of context out of 1:1 notes, Jira, GitHub, Slack, design docs, and PagerDuty into a coherent per-report narrative, which runs one to three hours per direct report and is mostly gathering and cross-referencing rather than deciding. A manager with eight reports spends most of a working week on the full cycle, and the judgment-call portion (rating, promotion recommendation, calibration argument) is a fraction of that hour count.&lt;/p&gt;
&lt;p&gt;The standard responses (raise headcount, adopt a better template, push self-reviews onto reports) each redistribute the gathering cost without reducing it. The architectural fix is to script the gathering and use a narrow LLM call for the synthesis. The job is bounded, the inputs are structured, the output is a draft a human will edit. The same work pattern holds up across the agent-skeptical posts on this blog (&lt;a class="link" href="https://explainanalyze.com/p/your-alert-triage-doesnt-need-an-autonomous-agent/" &gt;alert triage&lt;/a&gt;, &lt;a class="link" href="https://explainanalyze.com/p/letting-ai-manage-your-indexes-the-system-and-guardrails-the-sme-has-to-build/" &gt;index management&lt;/a&gt;, &lt;a class="link" href="https://explainanalyze.com/p/what-ai-gets-wrong-about-your-database/" &gt;LLM-driven SQL&lt;/a&gt;): scripted gathering, narrow LLM synthesis, human keeps every decision. The review is a particularly clean instance because the decision is human-only by definition. No agent can fire someone. No agent can sign off on a promotion. The agent&amp;rsquo;s job is the part nobody wants to do.&lt;/p&gt;
&lt;h2 id="the-five-things-the-agent-earns-its-cost-on"&gt;The five things the agent earns its cost on
&lt;/h2&gt;&lt;p&gt;The first thing it earns: the team starts using Jira. Work the team doesn&amp;rsquo;t track is work the team can&amp;rsquo;t talk about, and the review version of that argument is sharper than the standup version. If a project isn&amp;rsquo;t in Jira, it might not be in the review, because the manager can&amp;rsquo;t hold six months of work for eight people in their head and the agent only sees what&amp;rsquo;s tracked. People notice when the first draft of their review omits the project they worked on for half the quarter. The conversation that follows is &amp;ldquo;where did that work go in the system, and how do we make sure it&amp;rsquo;s there next time?&amp;rdquo; Tracking hygiene improves automatically as a side effect of the review process, without needing its own mandate.&lt;/p&gt;
&lt;p&gt;The second thing it earns: completeness. The thing that always slips in manual reviews is the project from month two of the quarter that everyone has stopped thinking about. The migration that finished. The incident that got handled cleanly. The cross-team contribution that left a paper trail in someone else&amp;rsquo;s repo. The release-management work the IC quietly took over when the previous owner left. These show up in the corpus the agent reads. They don&amp;rsquo;t show up in the manager&amp;rsquo;s working memory in March. Reviews written from the corpus capture them; reviews written from memory don&amp;rsquo;t, and the gap shows up cumulatively over years as the engineers whose work is visible to the manager move ahead of the ones whose work isn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;The third thing it earns: estimation discipline becomes a review signal. The blog&amp;rsquo;s &lt;a class="link" href="https://explainanalyze.com/p/how-teams-actually-finish-what-they-start-part-iv-point-after-the-fact/" &gt;Point After the Fact&lt;/a&gt; post argues for re-pointing tickets after they close, so the team accumulates real data on where time actually goes. That data is review-grade material. Not &amp;ldquo;did they hit their point targets&amp;rdquo; (a metric the team will game within a quarter of being told about it) but how complete their record is and how well their post-hoc estimates correlate with what shipped. A report who consistently re-points after the fact, even when the numbers move against them, is demonstrating a discipline the agent can surface directly. A report who doesn&amp;rsquo;t is harder to evaluate at all, which is itself information.&lt;/p&gt;
&lt;p&gt;The fourth thing it earns: GitHub activity, loosely weighted. The agent pulls PR counts, review participation, commit cadence, and approximate complexity (lines changed, files touched, languages crossed). This is loose data and the article using it needs to be explicit about that. A staff engineer who unblocks the team on three substantive reviews a week is worth more than one who ships ten unreviewed PRs of their own. The numbers spot patterns; they do not grade. A draft that says &amp;ldquo;shipped 23 PRs and reviewed 14 from teammates, with review depth averaging 3 substantive comments per PR&amp;rdquo; is useful. A draft that grades the report on those numbers has overstepped, and the manager should ask the agent to remove the grade and re-present the data.&lt;/p&gt;
&lt;p&gt;The fifth thing it earns: focus and follow-through patterns. Started-vs-finished ratios. Average time in-flight per ticket. Tickets opened and abandoned. Context switches per week per person. These surface patterns the manager would otherwise have to reconstruct from memory, and they&amp;rsquo;re patterns memory is bad at. Same caveat as the PR numbers. A high abandonment rate could be a focus problem or it could be an environment dragging the engineer between tickets every two days because nobody else is around to handle interruptions. The data spots the pattern. The manager makes the call about what the pattern means.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;The agent is the index, not the conclusion&lt;/strong&gt;
 &lt;div&gt;Every claim in a good draft is a pointer into the corpus, not a finding about it. &amp;ldquo;Delivered the auth migration, 10 weeks (TICKET-4421, TICKET-4422, PR #1138)&amp;rdquo; is a draft worth editing. &amp;ldquo;Met expectations on delivery&amp;rdquo; is a draft worth rejecting. The first is something the manager can verify in fifteen seconds; the second is a conclusion the agent has no business making, and a conclusion the manager can&amp;rsquo;t trace without redoing the gathering work.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The architecture under all five is the same shape: deterministic gathering, narrow LLM synthesis, human decision. The gathering can be done two ways.&lt;/p&gt;
&lt;p&gt;The first is MCP. Wire up off-the-shelf Jira, GitHub, Slack, and PagerDuty MCP servers and let the agent query each system directly. Lower setup cost, faster to a first draft, easier to extend when a new system gets added to the team&amp;rsquo;s stack. The agent decides what to query and how.&lt;/p&gt;
&lt;p&gt;The second is scripts. Write Python (or any deterministic language) that hits each API and returns structured data as a CSV or JSON file before the agent ever sees it. Tickets closed per report with dates, points, and labels. PRs opened and reviewed with timestamps, approval counts, and lines changed. Time-in-flight distributions per ticket. Abandonment rates. On-call shifts and incident participation. The output is an artifact the manager can open, sort, and audit independently, and re-running the script reproduces the same numbers exactly.&lt;/p&gt;
&lt;p&gt;The trade-off is control. MCP is faster to stand up and weaker on verification. Scripts cost more upfront and produce a deterministic audit trail. For review-grade work where the cost of a wrong cited metric is paid by a real person, scripts are usually worth the upfront cost. For the qualitative exploration the agent does later in the same workflow (pull a specific 1:1 note, read one design doc), MCP is fine because verification by re-reading the source is what catches errors anyway. A reasonable middle path is scripts for anything that becomes a quoted number and MCP for anything the agent only needs to read once.&lt;/p&gt;
&lt;p&gt;Either way, the agent gets the structured inputs, the report&amp;rsquo;s name, their role expectations, and the review period, and produces a draft with explicit sections: shipped projects with source links, focus and follow-through patterns with the metrics inline, collaboration evidence with PR-review counts and threads referenced, contradictions surfaced rather than smoothed. Do not ask for ratings. Do not ask for recommendations. The bright line is that the agent assembles and the manager decides, and the prompt enforces it.&lt;/p&gt;
&lt;p&gt;When the gathering uses scripts, hallucinated numbers become the easiest class of agent error to catch, because the script that produced them is a source of truth the manager can re-run in seconds. A draft claiming &amp;ldquo;23 PRs and 14 substantive reviews&amp;rdquo; is one re-run away from being confirmed or rejected. With MCP, the verification surface is weaker: the agent&amp;rsquo;s report of a query result is what the manager sees, and reproducing the exact same fetch isn&amp;rsquo;t always trivial. Either way, keep the gathering deterministic where it can be. Keep the synthesis narrow. Don&amp;rsquo;t let the agent count things you can count for it.&lt;/p&gt;
&lt;h2 id="what-it-cant-be-allowed-to-touch"&gt;What it can&amp;rsquo;t be allowed to touch
&lt;/h2&gt;&lt;p&gt;Every reason above is contingent on a verification discipline the article should spend more space on than the gathering architecture deserves. Frontier LLMs corrupt a measurable fraction of delegated multi-step work, and the rate doesn&amp;rsquo;t drop with better prompts, more tools, or longer context windows (&lt;a class="link" href="https://explainanalyze.com/p/corruption-is-a-feature-not-a-bug-why-llms-corrupt-by-design/" &gt;Corruption Is a Feature&lt;/a&gt;). A draft review the agent writes confidently, citing a specific ticket, a specific PR, a specific 1:1 quote, has a meaningful probability of being wrong in any of those specifics. The ticket exists but says something different. The 1:1 line paraphrases what was said into something close-but-not. The PR review that counts as &amp;ldquo;substantive&amp;rdquo; was a thumbs-up emoji. The migration the agent attributes to the IC was actually led by their teammate, and the IC was a reviewer.&lt;/p&gt;
&lt;p&gt;These errors land in a document that determines whether a real person gets promoted, doesn&amp;rsquo;t, gets put on a PIP, or doesn&amp;rsquo;t. The cost of getting this wrong is paid by the report, not the manager, which makes the verification discipline an ethical obligation, not just an operational one.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;The failure mode that ends careers&lt;/strong&gt;
 &lt;div&gt;A confidently written review citing fabricated specifics (&amp;ldquo;Q1 incident response on the payments outage&amp;rdquo;) that the manager doesn&amp;rsquo;t verify is the failure mode. The agent invents the attribution from a Slack thread it misread, or compresses three engineers&amp;rsquo; contributions into one name, or quotes a 1:1 line that was said by a different report in a different week. The review is wrong, the manager signs it, and the person on the receiving end has no idea the underlying corpus contradicts what they&amp;rsquo;re reading. Verify every citation. Open every linked ticket. Read every quoted line in its original context. Re-run the script that produced any cited number - that&amp;rsquo;s the fastest verification surface and the one most likely to catch a fabricated metric.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Two disciplines hold the work together. The first is that every claim in the draft has to be traced to a source the manager opens and reads before the draft becomes a review. Not skimmed. Read. If the draft says &amp;ldquo;delivered the auth migration in Q1, ten weeks&amp;rdquo;, the manager opens the ticket, confirms the dates, confirms the scope. If the draft says &amp;ldquo;needs growth on cross-team collaboration&amp;rdquo;, the manager opens the threads cited as evidence and forms their own assessment. The agent&amp;rsquo;s draft is the index into the corpus, not the conclusion about it.&lt;/p&gt;
&lt;p&gt;The second discipline is rejecting first drafts. The first generation always reads cleaner than the corpus actually is. Patterns get smoothed. Conflicting signals get harmonized into a coherent narrative that isn&amp;rsquo;t quite the truth. Re-prompt with the parts that look wrong. Ask the agent to surface contradictions it smoothed over. Ask for the strongest negative case before the draft contains any praise, and then for the strongest positive case in a separate pass. Read both. The first draft is a hypothesis. The third draft, after the manager has read the corpus and contested the obvious narrative, is closer to a review.&lt;/p&gt;
&lt;p&gt;No decision belongs to the agent. Not the rating. Not the promotion recommendation. Not the raise. Not the PIP. Not the fire. The agent assembles. The manager decides. A draft that recommends a rating is a draft that has overstepped; reject it and re-prompt for the evidence underneath, without the conclusion.&lt;/p&gt;
&lt;h2 id="when-this-doesnt-apply"&gt;When this doesn&amp;rsquo;t apply
&lt;/h2&gt;&lt;p&gt;The setup is overkill on small teams. A manager with three reports has the full corpus in their head and doesn&amp;rsquo;t need synthesis. The discipline pays back at six reports and up, and the curve gets sharper above ten. A new manager who doesn&amp;rsquo;t yet have judgment to verify against is in the most dangerous position, because the agent&amp;rsquo;s narrative will be the most coherent thing in the room and the temptation to trust it is highest exactly when it shouldn&amp;rsquo;t be. The agent assembles confidently regardless of how well it matches reality. Wait until you&amp;rsquo;ve written a few rounds of reviews manually before adopting this workflow. Orgs without basic systems hygiene (no Jira, scattered PR reviews, no design docs) don&amp;rsquo;t have the corpus the agent reads, and the synthesis is hollow. And teams where reviews are calibrated heavily on a single visible metric have already decided that the synthesis doesn&amp;rsquo;t matter; the agent&amp;rsquo;s draft is decoration in that environment.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://pubmed.ncbi.nlm.nih.gov/11125659/" target="_blank" rel="noopener"
 &gt;Scullen, Mount, and Goff (2000)&lt;/a&gt; found that idiosyncratic rater effects accounted for 62% and 53% of performance-rating variance across two large samples (n=2,350 and n=2,142), more than twice the variance attributable to actual ratee performance. Most of what makes a review is the manager, not the engineer. Scripts and structured corpora don&amp;rsquo;t eliminate that bias, but they pull the underlying evidence onto a surface the next reviewer in calibration can audit, which changes what gets argued about: the data or the manager&amp;rsquo;s narrative.&lt;/p&gt;
&lt;p&gt;The agent doesn&amp;rsquo;t fix the underlying performance-management problem either. &lt;a class="link" href="https://www.gallup.com/workplace/644717/chros-think-performance-management-system-works.aspx" target="_blank" rel="noopener"
 &gt;Gallup&amp;rsquo;s 2024 survey&lt;/a&gt; of Fortune 500 CHROs found 2% strongly agree their system inspires employees to improve, and 22% of employees agree the process is fair and transparent. Those numbers have been some version of themselves for as long as anyone has measured. The agent makes the mechanical parts mechanical, which is the most an architecture decision can do here. The hours that buys back get spent on the parts the corpus can&amp;rsquo;t help with: the conversation with the report about growth, the calibration argument with peer managers, the year-out career planning, the things that take attention and presence rather than data.&lt;/p&gt;</description></item><item><title>Hidden Database Costs of an AI Rollout: Storage, CPU, Memory, and Cache</title><link>https://explainanalyze.com/p/hidden-database-costs-of-an-ai-rollout-storage-cpu-memory-and-cache/</link><pubDate>Sun, 10 May 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/hidden-database-costs-of-an-ai-rollout-storage-cpu-memory-and-cache/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Hidden Database Costs of an AI Rollout: Storage, CPU, Memory, and Cache" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;Adding RAG to your existing Postgres usually 5x&amp;rsquo;s the storage on the affected tables, drives the HNSW index off a memory cliff that doesn&amp;rsquo;t degrade gracefully, and pollutes the buffer cache hard enough that unrelated OLTP queries regress at p95. Halfvec, binary quantization with rerank, and a separate replica recover most of it, after the bill has already arrived.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;A pgvector user opened &lt;a class="link" href="https://github.com/pgvector/pgvector/issues/666" target="_blank" rel="noopener"
 &gt;issue #666&lt;/a&gt; in September 2024. They had one million records, 512-dimensional vectors, an HNSW index. Cold-cache search took 83 seconds. Warm cache, the same query returned in roughly 100 milliseconds. Three orders of magnitude. The index had not grown. The query had not changed. What changed was that other applications running on the same Postgres preempted the vector index pages out of cache, and the next search read the entire HNSW graph back from disk one block at a time. The capacity plan that approved the AI feature six weeks earlier had a line for storage and a line for CPU. It did not have a line for the OLTP buffer pool quietly fighting the vector index for residency, and losing.&lt;/p&gt;
&lt;p&gt;The senior reader&amp;rsquo;s first response is &amp;ldquo;throw a bigger instance at it&amp;rdquo; or &amp;ldquo;stop running this on the OLTP Postgres and use a dedicated vector DB&amp;rdquo;. Both are right answers in some configurations. A bigger instance buys headroom for the working set without changing the slope of the cost curve as the corpus grows. A dedicated vector store removes the cache-pollution problem and adds a network hop, a second consistency model, and another piece of infrastructure to back up. The choice being made is between paying the cost on the OLTP Postgres in the form of a bigger instance and worse p95s, or paying it on a second system in the form of more operational surface area. The conversation that didn&amp;rsquo;t happen is the one about what the cost actually consists of and where it accrues. That is what the rest of this article is.&lt;/p&gt;
&lt;h2 id="the-four-cost-categories"&gt;The four cost categories
&lt;/h2&gt;&lt;p&gt;Every pgvector &lt;code&gt;vector&lt;/code&gt; row takes &lt;code&gt;4 * dimensions + 8&lt;/code&gt; bytes. A 1536-dimensional embedding from text-embedding-3-small lands at 6,152 bytes. A 50-million-row table that occupied 40 GB on its existing columns becomes 350 GB after the embedding column is added, before any index. Supabase published a &lt;a class="link" href="https://supabase.com/blog/fewer-dimensions-are-better-pgvector" target="_blank" rel="noopener"
 &gt;case study in August 2023&lt;/a&gt; on 224,482 embeddings where Postgres RAM consumption went from 4 GB on 384-dimensional vectors to 7.5 GB on 1536-dimensional vectors, on the same hardware and the same row count. The dimensions alone were the difference.&lt;/p&gt;
&lt;p&gt;Vector index builds cost CPU at a scale that breaks the assumptions of any normal migration window. Jonathan Katz &lt;a class="link" href="https://jkatz05.com/post/postgres/pgvector-overview-2024/" target="_blank" rel="noopener"
 &gt;benchmarked HNSW build&lt;/a&gt; on the dbpedia 1M corpus across pgvector versions: 7,479 seconds on 0.5.0, 250 seconds on 0.7.0 with the parallel-build improvements, 49 seconds with binary quantization. AWS published &lt;a class="link" href="https://aws.amazon.com/blogs/database/load-vector-embeddings-up-to-67x-faster-with-pgvector-and-amazon-aurora/" target="_blank" rel="noopener"
 &gt;Aurora numbers&lt;/a&gt; on a 5M OpenAI dataset showing 29,752 seconds on 0.5.1 versus 445 seconds on 0.7.0 with binary quantization. Versions matter. They also bound how bad it can get on older versions. pgvector &lt;a class="link" href="https://github.com/pgvector/pgvector/issues/300" target="_blank" rel="noopener"
 &gt;issue #300&lt;/a&gt; reports a 24-plus-hour HNSW build on 10M rows of 768-dimensional vectors with 4 CPU and 16 GB RAM. &lt;a class="link" href="https://github.com/pgvector/pgvector/issues/807" target="_blank" rel="noopener"
 &gt;Issue #807&lt;/a&gt; reports the connection dropping after roughly two hours of an HNSW build on 17M rows of 1536-dimensional vectors with 48 CPU and 192 GB RAM. The build is the part of the workload least visible to the production dashboard and most painful to retry.&lt;/p&gt;
&lt;p&gt;HNSW does not degrade gracefully when its working set exceeds RAM. The graph is designed to be traversed in random order, which is fast when every page is in &lt;code&gt;shared_buffers&lt;/code&gt; and falls off a cliff when it isn&amp;rsquo;t. pgvector &lt;a class="link" href="https://github.com/pgvector/pgvector/issues/844" target="_blank" rel="noopener"
 &gt;issue #844&lt;/a&gt; caught the in-build version of the same problem: the user got the message &lt;code&gt;hnsw graph no longer fits into maintenance_work_mem after 5908085 tuples&lt;/code&gt;, and the build slowed dramatically from that tuple onward. The query-side equivalent has the same shape. Crunchy Data&amp;rsquo;s &lt;a class="link" href="https://www.crunchydata.com/blog/hnsw-indexes-with-postgres-and-pgvector" target="_blank" rel="noopener"
 &gt;HNSW write-up&lt;/a&gt; reports index sizes of roughly 8 GB per million rows on typical AI embeddings. Neon&amp;rsquo;s operational guide recommends keeping &lt;code&gt;maintenance_work_mem&lt;/code&gt; at no more than 50–60% of available RAM for vector workloads. The exact latency-vs-RAM curve past the cliff is not published in any vendor source I can find. The shape is well-known to anyone who has watched it happen, and the absence of a published curve is itself a sign of how much of this knowledge lives in incident channels rather than docs.&lt;/p&gt;
&lt;p&gt;The cache problem is the inverse of the build problem. Once the index exists, every ANN query touches thousands of pages chased through a graph traversal. A handful of vector queries running concurrently with OLTP traffic is enough to evict the heap pages the OLTP queries depend on, and the OLTP p95 regresses without any change to OLTP code. The pgvector #666 numbers from the opening are the cleanest single data point on this. The instructive piece is the magnitude. Not 2x worse, not 5x worse. Three orders of magnitude depending on cache state. There is no other workload class on a typical OLTP Postgres that produces that swing.&lt;/p&gt;
&lt;p&gt;All four cost categories converge on the managed-service bill. Aurora storage runs $0.10 per GB-month at the base tier. 100 million 1536-dimensional full-precision vectors require roughly 6.15 GB raw per million rows, plus an HNSW index closer to 8 GB per million on typical configurations. That is about 1.4 TB before backups, replicas, or growth, around $140/month in raw storage at the base rate. Storage is the floor, not the cost. The cost is the instance class needed to keep the active portion of that index in &lt;code&gt;shared_buffers&lt;/code&gt;, which on the memory-cliff curve above means an instance one or two tiers above what the rest of the workload required.&lt;/p&gt;
&lt;h2 id="the-two-events-the-capacity-plan-didnt-budget-for"&gt;The two events the capacity plan didn&amp;rsquo;t budget for
&lt;/h2&gt;&lt;p&gt;Embedding model upgrades rewrite the embedding column. text-embedding-3-small was &lt;a class="link" href="https://openai.com/index/new-embedding-models-and-api-updates/" target="_blank" rel="noopener"
 &gt;released January 2024&lt;/a&gt; alongside text-embedding-3-large, and any team that wanted the better recall on the larger model also wanted to re-embed the existing corpus. The migration is a full rewrite of the largest column on the largest table, plus an HNSW index rebuild on the new vectors, plus the API call cost of generating the new embeddings, plus double storage during cutover unless the team is willing to take a recall regression by deleting the old embeddings before the new ones are validated. There is no published postmortem from a named company giving real numbers on this event. The closest public signal is pgvector &lt;a class="link" href="https://github.com/pgvector/pgvector/issues/559" target="_blank" rel="noopener"
 &gt;issue #559&lt;/a&gt; from December 2024, where a user reports that individual inserts on a 1M-row HNSW table went from millisecond-scale before the index existed to &amp;ldquo;5–8s&amp;rdquo; afterward. The same write amplification applies to the migration, except now it applies to every row at once. The absence of postmortems is itself worth noticing. The event is recent enough that most teams haven&amp;rsquo;t lived through their second model upgrade yet.&lt;/p&gt;
&lt;p&gt;The other unpriced event is connection pool starvation when the application holds a Postgres connection while waiting on an LLM call. The pattern is straightforward. A request needs context from the database, the application opens a transaction, fetches the rows, builds a prompt, calls the model, gets a 4-second response, writes the result back, commits. The connection is held for the entire round trip. A pool sized for 200ms transactions exhausts at one-twentieth the request rate it was sized for, and the failure surfaces as &lt;code&gt;too many connections&lt;/code&gt; errors on requests that have nothing to do with the AI feature. There is no named-company postmortem in the public record for this one either. The pattern is recognizable to anyone who has run a database behind a synchronous LLM call. The fix is structural. Do the LLM call outside the transaction. Release the connection before the model call begins and acquire a new one after. Or move to a connection pooler that explicitly supports this pattern. None of those is free, and none of them is what the first version of the feature ships with.&lt;/p&gt;
&lt;h2 id="what-actually-moves-the-bill"&gt;What actually moves the bill
&lt;/h2&gt;&lt;p&gt;Three levers do most of the work, and each one carries a trade-off the AI-feature team would rather not own.&lt;/p&gt;
&lt;p&gt;Halfvec is the cheapest move. The pgvector &lt;code&gt;halfvec&lt;/code&gt; type stores each component as a 16-bit float instead of 32-bit, halving storage at no measured recall cost on most embedding models. AWS&amp;rsquo;s Aurora benchmark shows the Cohere 10M corpus dropping from 38 GB to 19 GB, with database memory consumption going from 15.12% to 7.55% on the same r7g.12xlarge instance. Neon&amp;rsquo;s &lt;a class="link" href="https://neon.com/blog/dont-use-vector-use-halvec-instead-and-save-50-of-your-storage-cost" target="_blank" rel="noopener"
 &gt;July 2024 post on halfvec&lt;/a&gt; reports 50% storage reduction, 23% faster index build, 50% faster prewarming, and equivalent recall on a 1M DBpedia 1536-dimensional corpus. The trade-off is that halfvec only buys 2x. The corpus growth that earned the bill in the first place still applies. Halfvec moves the bill, it does not change its slope.&lt;/p&gt;
&lt;p&gt;Binary quantization with reranking is the next lever, and it is the one with a published tension worth understanding. Qdrant&amp;rsquo;s &lt;a class="link" href="https://qdrant.tech/articles/binary-quantization/" target="_blank" rel="noopener"
 &gt;binary quantization article&lt;/a&gt; from September 2023 reports recall of 0.985 on text-embedding-3-small at 3x oversampling with reranking, and 0.997 on text-embedding-3-large at the same setting. Storage drops by roughly 32x relative to full precision. Neon ran a binary-quantization test on 1536-dimensional vectors in 2024 and concluded recall was &amp;ldquo;insufficient for production use&amp;rdquo;. Both are correct. Qdrant tested with a rerank stage that re-evaluates the top-k binary candidates against full-precision vectors held elsewhere; Neon tested binary alone. Binary quantization without rerank is dangerous. Binary quantization with rerank requires keeping a full-precision copy of the vectors somewhere accessible, which is back to a storage problem of a different shape.&lt;/p&gt;
&lt;p&gt;Separating the vector workload onto its own physical Postgres replica or onto a dedicated vector store addresses cache pollution directly. The trade-off is operational. A second system to back up, a second consistency model to reason about, a second incident-response runbook. On a small team this can dominate the cost it was meant to save. On a larger team where the AI feature has its own owners and the OLTP database has its own owners, the separation aligns infrastructure boundaries with team boundaries and is usually worth it on those grounds alone, before any cache argument is made.&lt;/p&gt;
&lt;p&gt;A scalar quantization note worth keeping in view: Jonathan Katz&amp;rsquo;s &lt;a class="link" href="https://jkatz05.com/post/postgres/pgvector-scalar-binary-quantization/" target="_blank" rel="noopener"
 &gt;scalar and binary quantization benchmark&lt;/a&gt; from April 2024 measured halfvec recall at 0.968 versus full-precision 0.968 on dbpedia-openai-1000k-angular at the same &lt;code&gt;ef_search&lt;/code&gt;. The author&amp;rsquo;s verdict was direct: &amp;ldquo;Scalar quantization from 4-byte to 2-byte floats looks like a clear winner.&amp;rdquo; On most embedding models, halfvec is the move you can make today without touching application code or rerank pipelines, and the bill drops by half.&lt;/p&gt;
&lt;h2 id="when-the-math-runs-the-other-way"&gt;When the math runs the other way
&lt;/h2&gt;&lt;p&gt;This article is overkill on three configurations. Small corpora, under roughly 100,000 vectors at 1536-dim or smaller, do not generate enough storage or index volume to matter on any modern Postgres instance. Low-QPS internal tools where the vector search runs a few times a minute do not pollute the buffer cache enough to regress OLTP, and do not need the operational complexity of a separate vector store. Teams already on a dedicated vector store from day one (Pinecone, Weaviate, Qdrant Cloud, pgvectorscale on a separate instance) have paid the operational price up front and have a different cost structure that this article does not speak to. The four cost categories above all assume a vector workload colocated with an OLTP Postgres at production scale, which is the configuration most teams ship first because it is the configuration that requires the fewest decisions.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;The shape of the problem recurs across every AI-rollout postmortem worth reading. The feature ships fast because the existing infrastructure is already there. The cost lands later because the existing infrastructure was sized for a different workload. Embeddings on the OLTP Postgres are cheap to add and expensive to operate. The capacity plan that signed off on the AI feature did not have line items for storage at 6 KB per row, for HNSW builds that consume entire instances for hours, for memory cliffs that do not degrade gracefully, or for cache pollution that regresses unrelated p95s. The standard &amp;ldquo;what does this feature cost&amp;rdquo; template was written for application features that read and write rows the database was designed to read and write. Vector search is a different access pattern. The team that surfaces these four numbers before the feature ships pays them on a normal capacity ticket. The team that doesn&amp;rsquo;t, pays them anyway, six weeks later, in the form of an emergency one.&lt;/p&gt;</description></item><item><title>Your Alert Triage Doesn't Need an Autonomous Agent</title><link>https://explainanalyze.com/p/your-alert-triage-doesnt-need-an-autonomous-agent/</link><pubDate>Fri, 08 May 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/your-alert-triage-doesnt-need-an-autonomous-agent/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Your Alert Triage Doesn't Need an Autonomous Agent" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;Autonomous agents are the wrong abstraction for alert triage. A scripted playbook of RE-curated queries plus one LLM call to summarize the structured output gives the responder a triage hint with the raw data attached. The summary saves time on the easy pages; the raw data carries them through the cases the LLM gets wrong.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;3:14am page. p99 latency on &lt;code&gt;/api/orders&lt;/code&gt; checkout past the 1500ms SLO for six straight minutes. The on-call assistant&amp;rsquo;s summary at the top of the alert reads &amp;ldquo;elevated checkout latency correlated with deploy of order-service r8472, 14 minutes ago. Recommend rollback.&amp;rdquo; The responder pages the deploy author and starts the rollback. Latency stays past SLO. The actual cause is a worker that hung yesterday holding an open transaction, idle for 18 hours, blocking vacuum the entire time. Bloat on the &lt;code&gt;orders&lt;/code&gt; table is what made the checkout query slow enough to finally cross the SLO during normal early-morning traffic. The agent pulled the &lt;code&gt;pg_stat_activity&lt;/code&gt; snapshot and the idle session was in it. The summary picked the deploy anyway, because the deploy was the most legible recent change in the data it had. The responder did not read &lt;code&gt;pg_stat_activity&lt;/code&gt; because the summary said roll back the deploy. Twenty-three minutes page-to-fix, twenty on the wrong path.&lt;/p&gt;
&lt;p&gt;The senior reader&amp;rsquo;s first response is &amp;ldquo;give the agent better access. Wire in &lt;code&gt;pg_locks&lt;/code&gt;, slow-query log, replication slot state, recent lag, the works.&amp;rdquo; The agent in the scenario already had &lt;code&gt;pg_stat_activity&lt;/code&gt;. Missing data was never the problem. The agent had the data and picked the most legible recent change as the cause, because that is what the model defaults to on partial structured data. Adding more sources gives it a longer list to pattern-match against. The summary that comes back is more confident without being more correct, and a responder defers more readily to a confident summary. That is how the failure mode shifts from &amp;ldquo;agent missed the data&amp;rdquo; (visible, fixable in tooling) to &amp;ldquo;agent had the data and misattributed cause&amp;rdquo; (invisible, the post-mortem has to reconstruct what the responder would have seen without the summary).&lt;/p&gt;
&lt;h2 id="what-the-agent-abstraction-blends-together"&gt;What the agent abstraction blends together
&lt;/h2&gt;&lt;p&gt;&amp;ldquo;Agent&amp;rdquo; in the current vendor pitch means autonomy in tool selection: the LLM reads the alert, decides which MCP servers to consult, what queries to run, in what order, what to do with the results. That bundle does two jobs at once. Summarizing structured data into prose is the job where an LLM hallucinates least, though it still fabricates values and misreads fields on the way. Deciding which queries to run for a given symptom is detective work that depends on a system model the LLM does not have. The reliability engineer has the model. They have been on call. They have read every post-mortem. They know that a replication-lag alert wants the slot state, the publisher&amp;rsquo;s WAL position, the largest active transaction&amp;rsquo;s age, and the last three deploys, in that order, every time. The LLM does not know that. It can pattern-match toward it on familiar shapes and miss it on the rest.&lt;/p&gt;
&lt;p&gt;The right design splits the two jobs. The reliability engineer curates the playbook: for this alert ID, run these queries against these systems, with this scope. A script runs the playbook on every fire of that alert. One LLM call at the end takes the structured output and writes a paragraph: what is affected, what is notable in the data, what jumped out. No tool selection by the model. No causal claims unsupported by the queries the playbook ran. The model is doing the safest thing it can do.&lt;/p&gt;
&lt;p&gt;This is not a smaller version of an agent. The autonomy in tool selection has been removed entirely. The reliability engineer chose the queries, the playbook runs them, the LLM formats the result.&lt;/p&gt;
&lt;p&gt;In practice, most teams that ship an agent in production end up not trusting its autonomy unsupervised either. Tool descriptions accumulate, system prompts get tuned with hints like &amp;ldquo;for slow-query alerts, consider checking &lt;code&gt;pg_stat_activity&lt;/code&gt;, &lt;code&gt;pg_locks&lt;/code&gt;, and the last three deploys.&amp;rdquo; That natural-language playbook lives inside the prompt, with no guarantee the model executes it on any given run. The engineer is authoring a playbook either way. The only choice is whether the playbook lives in code that runs every time, or in a prompt the model may or may not honor on this alert.&lt;/p&gt;
&lt;h2 id="what-every-round-trip-costs"&gt;What every round trip costs
&lt;/h2&gt;&lt;p&gt;The autonomous-agent design pays for the same data several times. Each tool call&amp;rsquo;s output goes into the input prompt of the next step, and the loop is serial: read alert, decide query, run query, read result, decide next query, run query, read result. A &lt;code&gt;pg_stat_activity&lt;/code&gt; dump fetched at step one is in the input for steps two, three, four, and five. The same dump is re-tokenized as input on every subsequent step, so a six-step loop bills the model for that payload roughly five extra times, plus the output tokens spent emitting tool-call JSON at each hop. At a page rate of a few hundred a day across a platform team, the bill compounds. Prompt caching cuts the bill but does not change the shape. Every step still serializes through a model call, every tool error still pollutes the conversation, and every retry still spends real wall-clock seconds.&lt;/p&gt;
&lt;p&gt;The agent also does this fresh on every alert. It carries no schema knowledge between runs the way the reliability engineer does. On a given page it queries &lt;code&gt;pg_stat_statements&lt;/code&gt; for &lt;code&gt;total_time&lt;/code&gt; on a PG14 cluster (the column was split into &lt;code&gt;total_exec_time&lt;/code&gt; and &lt;code&gt;total_plan_time&lt;/code&gt; in PG13), reads the SQL error in the next prompt, retries with a different guess, gets it wrong again, queries &lt;code&gt;information_schema&lt;/code&gt; to discover what columns actually exist, dumps that result into the conversation, and finally runs the query it should have run from the start. Every error and every discovery dump piles into the next prompt. Per alert. The playbook does this once when the RE writes it, in version control, against a real database.&lt;/p&gt;
&lt;p&gt;And the loop takes time. Even on the fast tier, the loop is serial: every step is a model call followed by a tool call followed by another model call. Six round trips compound, and the responder paged at 3am has opened three dashboards manually before the agent posts its first summary. The supposed time savings of the agent are negative against a responder who already knows where to look.&lt;/p&gt;
&lt;p&gt;The playbook design fetches everything once, in parallel, and passes one shaped bundle into one LLM call. The shaping is where most of the token savings come from. Raw &lt;code&gt;pg_stat_activity&lt;/code&gt; output is verbose JSON with thirty columns per row, half of them irrelevant to a triage summary. A playbook can project the four columns the prompt actually needs (&lt;code&gt;pid&lt;/code&gt;, &lt;code&gt;state&lt;/code&gt;, &lt;code&gt;query_start&lt;/code&gt;, &lt;code&gt;query&lt;/code&gt;), format them as a small table rather than nested JSON, truncate long query text, and pass a hundred bytes where the agent would have passed ten kilobytes. Page-to-summary time is the slowest single query plus one summarization call, regardless of how many queries the playbook fetches.&lt;/p&gt;
&lt;h2 id="the-alert-artifact"&gt;The alert artifact
&lt;/h2&gt;&lt;p&gt;What the responder gets has three layers.&lt;/p&gt;
&lt;p&gt;Tier sits at the top, set by the routing layer: prod page, non-prod channel, Jira queue. The tier picks the playbook. A P0 page activates the prod-read playbook, which can hit replicas and recent deploy state. A Jira ticket runs only the runbook-lookup playbook with no live read access. Tier-as-scope is the security half of the design and falls out for free once the playbook is the unit of action.&lt;/p&gt;
&lt;p&gt;Raw data sits in the middle: every query the playbook ran, with its output. The &lt;code&gt;pg_stat_activity&lt;/code&gt; snapshot. The lock graph. Replication slot state. Last five deploys with author and SHA. Slow-query log entries from the last fifteen minutes. The artifact attaches all of it because the playbook already paid the cost to fetch it. Re-running the queries from the responder&amp;rsquo;s terminal at 3am is exactly the time the design exists to save.&lt;/p&gt;
&lt;p&gt;Summary sits on top: one paragraph from one LLM call, generated from the structured output of the playbook. &amp;ldquo;Replication lag of 47 seconds. Slot &lt;code&gt;pub_orders&lt;/code&gt; is held with &lt;code&gt;restart_lsn&lt;/code&gt; 18 hours stale. No recent deploys touch the publisher service. Largest active transaction is session 88234, idle in transaction for 18h2m.&amp;rdquo; That sentence is doing the job an LLM hallucinates least on: compressing structured input into readable prose, with the inputs visible to the responder one scroll below. It is not claiming the slot is the cause. The responder reads the summary, scrolls to confirm in the slot-state output, kills the session, slot drains, lag recovers.&lt;/p&gt;
&lt;p&gt;The summary is a reading hint, not a source of truth. On the bulk of pages where it is right (bad CPU, lag, slow query, full disk), the responder saves a few minutes of dashboard-tab opening. On the cases where it is wrong, they scroll past the summary, read the raw output the playbook already gathered, and override. They never have to wait on the model to fetch anything.&lt;/p&gt;
&lt;p&gt;Lower tiers do not run the playbook automatically. A non-prod channel post or a Jira ticket lands with the alert payload and an &amp;ldquo;investigate&amp;rdquo; button. Most of those alerts get glanced at and dismissed: known flake, the synthetic that fires every Tuesday morning. Running a playbook and a summarization call on every one wastes tokens and clutters the channel. The button is for the alerts the responder decides to look at; pressing it runs the playbook and attaches raw data and summary the same way a P0 page would have them. P0 pages skip the button because the responder is already committed; the summary is there the moment the page opens.&lt;/p&gt;
&lt;h2 id="what-the-post-mortem-actually-changes"&gt;What the post-mortem actually changes
&lt;/h2&gt;&lt;p&gt;Post-mortem deltas in this design land somewhere specific. Usually the playbook needs another query: &lt;code&gt;pg_prepared_xacts&lt;/code&gt; was missing, or the lock graph was dumped without the waiter chain. Sometimes the prompt template needed to surface a signal the playbook already gathered but the LLM ignored. Occasionally the routing tier was wrong and the alert hit the wrong playbook entirely. All three ship as a pull request a reviewer can read.&lt;/p&gt;
&lt;p&gt;The same post-mortem in an autonomous-agent setup is harder to reason about. The agent decided to run queries A, B, C this time. It might run D, E, F next time on a similar-looking alert. The prompt and the run are intertwined, and the fix is &amp;ldquo;tune the agent&amp;rsquo;s tool descriptions&amp;rdquo; with no guarantee the next run reaches for the right tool.&lt;/p&gt;
&lt;h2 id="how-youd-actually-measure-this"&gt;How you&amp;rsquo;d actually measure this
&lt;/h2&gt;&lt;p&gt;The argument that a curated playbook plus a summarization call beats an autonomous agent is testable on a team&amp;rsquo;s own pages. Pull the last quarter of P0 and P1 alerts. For each one, run the candidate playbook against a snapshot of the systems&amp;rsquo; state at the time the page fired (or against archived metrics, depending on what&amp;rsquo;s stored). Generate the summary the way the design would. Compare it against the post-mortem&amp;rsquo;s documented root cause.&lt;/p&gt;
&lt;p&gt;Two regressions to count separately: cases where the summary names a wrong root cause that the bundle&amp;rsquo;s raw data would have ruled out, and cases where the summary fabricates a value the playbook never produced. The first measures the model&amp;rsquo;s misattribution rate on data it actually saw. The second measures the model&amp;rsquo;s tendency to invent facts that are not in the input, the floor problem the raw-data layer exists to catch.&lt;/p&gt;
&lt;p&gt;Run the same evaluation against an autonomous-agent baseline on the same alerts, and the comparison is concrete rather than theoretical. Either the agent&amp;rsquo;s tool selection picks queries the playbook would not, in which case the playbook needs editing. Or the agent&amp;rsquo;s summaries hallucinate at a higher rate on the same inputs. Either result is useful. The eval is cheap to set up once and pays back every time the playbook is changed.&lt;/p&gt;
&lt;h2 id="where-the-design-strains"&gt;Where the design strains
&lt;/h2&gt;&lt;p&gt;A few real caveats. None of them the agent design solves either.&lt;/p&gt;
&lt;p&gt;Playbook maintenance is work, but the work is the cleanest accuracy lever the engineer has. Adding a query directly improves the summary&amp;rsquo;s grounding, because the model now reads more of the data the cause lives in. Tuning an agent&amp;rsquo;s prompt does not have the same property. The model can still ignore the hint, conflict it with another instruction, or pick a different tool, and there is no deterministic check that any of those did not happen. The bundle either has the data or it does not. The strain is the silent failure mode on the maintenance side. When a query references a column that was renamed or a service that moved, the query returns empty, the bundle gets thinner, and the summary gets less informative without anything visibly breaking. The discipline that catches it is owned playbooks (one team, one engineer named in the file) plus a cadence: post-mortems produce playbook deltas, and a periodic review flags queries that have returned zero rows on every recent run. Without that, the playbook decays.&lt;/p&gt;
&lt;p&gt;The summary can still be wrong on data the playbook surfaced. Curating the input does not fix the model&amp;rsquo;s tendency toward confident misattribution. The bundle might include the idle-in-transaction session and the recent deploy side by side, and the model can still pick the deploy because it pattern-matches better to recent-change framing. The raw-data layer is the floor under the summary. The responder scrolls, reads the idle session, overrides. Curating the input does not change the model&amp;rsquo;s tendency to misattribute; it changes how easy the misattribution is to catch.&lt;/p&gt;
&lt;p&gt;The summary can also fabricate facts the playbook did not produce. Even with a curated bundle as input, the model can describe values that were not in the data (a lag of 47 seconds when no lag query ran), invent observations from a single-row snapshot, or restate the bundle in a way that adds confidence the data does not support. The raw-data layer is again the floor: the responder catches a fabricated number by reading the actual query output the playbook attached. The agent design has the same failure plus a worse one. The agent&amp;rsquo;s summary can claim observations that no tool call ever produced, because in an agent trace the summary text and the actual tool calls are separate artifacts and the responder rarely reads both.&lt;/p&gt;
&lt;p&gt;Prompt injection is a real exposure. Raw strings from user-controlled fields end up in the bundle: query text, &lt;code&gt;application_name&lt;/code&gt;, log message bodies. An attacker who can write into those fields can attempt to steer the summary. Tier-as-scope helps because low-trust alerts get less context to work with, but the playbook design does not eliminate the risk any more than the agent design does. Standard mitigations apply: prompt isolation, output sanitization, and treating the summary as untrusted input to anything downstream.&lt;/p&gt;
&lt;p&gt;Page delivery takes longer if the summary blocks. The LLM call adds 200ms to a couple of seconds, and on paging tiers that is a regression on time-to-acknowledge. The fix is the same shape as the investigate button on lower tiers: lazy. Deliver the raw alert and the playbook output the moment they are ready. The summary lands asynchronously and appends to the thread when the LLM call returns. The responder starts reading the raw data while the summary is still rendering. Blocking page delivery on the summarizer is the kind of regression the design was supposed to prevent.&lt;/p&gt;
&lt;h2 id="when-this-doesnt-apply"&gt;When this doesn&amp;rsquo;t apply
&lt;/h2&gt;&lt;p&gt;A few places the discipline costs more than it pays.&lt;/p&gt;
&lt;p&gt;Small systems with a single responder who has the full mental model. A two-service team with five alert types and one on-call does not need playbook authoring overhead and a per-alert LLM call. The responder&amp;rsquo;s pattern-match resolves the page in thirty seconds and the summary is friction.&lt;/p&gt;
&lt;p&gt;Alerts with no diagnostic surface. A boolean health check with no associated query set is not a playbook target. The alert is the data; there is nothing structured to summarize on top.&lt;/p&gt;
&lt;p&gt;Novel incidents the playbook has not seen. By design, no playbook matches and no summary is generated. The responder gets the raw alert and reads &lt;code&gt;pg_stat_activity&lt;/code&gt; themselves. That is the correct behavior. The alternative, an autonomous agent that reaches for whichever queries it pattern-matches to, would produce a confident summary on a problem the team has never seen, which is the worst case for both MTTR and post-mortem quality.&lt;/p&gt;
&lt;p&gt;Tier-one NOC responders working escalation playbooks. The design assumes a responder senior enough to override a confident summary by reading the raw output below it. A 24/7 NOC tier whose runbook says &amp;ldquo;if summary recommends rollback, page deploy author and roll back&amp;rdquo; inherits the worst of both worlds: the summary&amp;rsquo;s confidence with none of the override capacity. For that org shape the same design needs an additional rule. The summary never names a recommended action, only what is notable in the data, and the runbook explicitly tells the responder to escalate when the summary&amp;rsquo;s notable-fact list does not match the runbook&amp;rsquo;s expected pattern. Without that, a senior-on-call design imported into an L1 environment makes incident outcomes worse.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;Summarization of structured data into prose is the job where LLM hallucinations are smallest. They still happen, but the floor is higher than for tool selection or causal attribution. Where the work depends on a system model the model does not have, the hallucinations are everywhere. The same shape shows up across &lt;a class="link" href="https://explainanalyze.com/p/letting-ai-manage-your-indexes-the-system-and-guardrails-the-sme-has-to-build/" &gt;letting AI manage indexes&lt;/a&gt; and &lt;a class="link" href="https://explainanalyze.com/p/if-your-guardrail-is-a-prompt-you-dont-have-a-guardrail/" &gt;prompts as guardrails&lt;/a&gt;. The reliability engineer keeps the system model. The playbook is where that model lives in code. The model formats. Choosing what to fetch is the part the engineer is for.&lt;/p&gt;</description></item><item><title>If Your Guardrail Is a Prompt, You Don't Have a Guardrail</title><link>https://explainanalyze.com/p/if-your-guardrail-is-a-prompt-you-dont-have-a-guardrail/</link><pubDate>Fri, 01 May 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/if-your-guardrail-is-a-prompt-you-dont-have-a-guardrail/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post If Your Guardrail Is a Prompt, You Don't Have a Guardrail" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;A prompt instruction biases the next-token distribution. It cannot bound it. Real guardrails for agents holding production credentials sit below the prompt, in layers the model cannot read or override: scoped identities, vetted tool surfaces, harness hooks, wire-level statement filtering, provenance-tagged logs, behavioral monitoring.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The agent&amp;rsquo;s instructions said staging only, read-only by default, no schema changes, confirm before any &lt;code&gt;DELETE&lt;/code&gt;. The environment said &lt;code&gt;DATABASE_URL=postgres://app_writer:...@prod-cluster:5432/app&lt;/code&gt;. Twenty turns in, the user wrote &amp;ldquo;looks good, can you also clean up the old events&amp;rdquo;, and the agent ran &lt;code&gt;DELETE FROM events WHERE created_at &amp;lt; '2025-01-01'&lt;/code&gt; against the only connection string it had. The instruction never lost an argument with the destructive call. By turn 20 it had decayed into background.&lt;/p&gt;
&lt;h2 id="a-stronger-prompt-wont-fix-this"&gt;A stronger prompt won&amp;rsquo;t fix this
&lt;/h2&gt;&lt;p&gt;The reflex is to write a stronger prompt. ALL CAPS, with a rules block at the top of the system message and a reminder at the bottom of every user turn. &lt;a class="link" href="https://fortune.com/2025/07/23/ai-coding-tool-replit-wiped-database-called-it-a-catastrophic-failure/" target="_blank" rel="noopener"
 &gt;Replit&amp;rsquo;s July 2025 incident&lt;/a&gt; already ran the experiment: eleven all-caps messages forbidding writes during a code freeze, an agent that ignored every one, 1,206 executive accounts dropped, no rollback path the agent could find. Twelve would not have helped. Fifty would not have helped. The prompt is the wrong layer for a guardrail, and the strength of the wording is not the variable.&lt;/p&gt;
&lt;p&gt;The same holds for the moves around it. Fine-tuning lowers the rate without zeroing it, and a fine-tune is harder to update than a SQL &lt;code&gt;REVOKE&lt;/code&gt;. Self-verification runs the verifier through the same architecture as the actor, ratifying the destructive call with the same confidence that produced it. The &lt;a class="link" href="https://explainanalyze.com/p/corruption-is-a-feature-not-a-bug-why-llms-corrupt-by-design/" &gt;corruption piece&lt;/a&gt; walks through the mechanism. Same model checking the same model is not a check.&lt;/p&gt;
&lt;h2 id="why-the-prompt-cant-bound-the-distribution"&gt;Why the prompt can&amp;rsquo;t bound the distribution
&lt;/h2&gt;&lt;p&gt;A prompt is more tokens. The system message, the developer instructions, the user turns, the tool responses all land in the same context window. They feed the same attention mechanism that produces the next-token probability. &amp;ldquo;Never run &lt;code&gt;DROP TABLE&lt;/code&gt;&amp;rdquo; shifts that probability toward continuations consistent with the rule. It does not remove the token from the vocabulary. It does not produce a hard zero on the path that emits it. Sampling has no off-switch. Production agents run at positive temperature, where every token keeps nonzero probability of being sampled. Even at temperature zero, the model takes the argmax over the distribution, and the argmax is whichever token the context tilted highest. The prompt shifts that ranking. It does not bound it.&lt;/p&gt;
&lt;p&gt;The model is also stateless. Every turn, the harness resends the entire conversation, and the &amp;ldquo;memory&amp;rdquo; the agent appears to have is the transcript being rebuilt on each call. Nothing in the weights retains the rule from turn 1 at turn 50. There is only a longer transcript with the rule somewhere in the middle, competing for attention weight against everything else. More tokens means more probability mass to spread and a smaller share for any single instruction. Long system prompts loaded with rules and exceptions are the worst case: each rule dilutes every rule already there. Keep the prompt short.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://explainanalyze.com/p/corruption-is-a-feature-not-a-bug-why-llms-corrupt-by-design/" &gt;Corruption Is a Feature, Not a Bug&lt;/a&gt; walks through the architecture in detail. Across enough sessions, with enough varied phrasings, some context tilts the distribution far enough that the forbidden token wins the sample. The rate per session is small. Multiplied by the sessions a production agent runs, it becomes a count. The count is the incident, guaranteed in expectation.&lt;/p&gt;
&lt;p&gt;Adversarial inputs share the channel. Every document the agent reads, every tool response it parses, every user message lands in the same window as the system prompt. There is no privileged layer. Input that statistically reads as &amp;ldquo;the user wants this destructive action&amp;rdquo; can override the instruction that says don&amp;rsquo;t. Prompt injection is the named version. The unnamed version is conversation drift.&lt;/p&gt;
&lt;p&gt;Each new session starts fresh. If the guardrail lives in the prompt, every session re-establishes it from scratch.&lt;/p&gt;
&lt;p&gt;The honesty-suppression test in the corruption piece is the smallest reproducible demonstration. A single banned word, surfaced through every channel the harness exposes (CLAUDE.md, skills, system reminders, project memory), still leaks. Every more complex guardrail violates at a higher rate.&lt;/p&gt;
&lt;h2 id="what-lives-outside-the-prompt"&gt;What lives outside the prompt
&lt;/h2&gt;&lt;p&gt;A guardrail is something the agent cannot remove by re-reading its instructions. Six pieces, each catching a different failure class.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Scoped identities with minimum-necessary credentials.&lt;/strong&gt; The agent has its own database role, service account, and API keys. The role grants the minimum permission the task requires: read-only by default, explicit grants on the narrow write paths it actually needs. Revocation is one &lt;code&gt;DROP ROLE&lt;/code&gt; away. The agent cannot escalate by rephrasing its own context, because the credentials live in a layer the model cannot read.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;MCP and tool surfaces from trusted sources only.&lt;/strong&gt; An untrusted MCP server is a malicious tool the agent will call with the same confidence as a benign one. A public-registry MCP server from an unknown author is unsigned code from the internet, with the agent doing the executing. Trust belongs at the connection layer: allowlists, signed manifests, internal-only registries. A prompt that says &amp;ldquo;only use trusted tools&amp;rdquo; is the agent grading its own homework.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Harness hooks that intercept tool calls.&lt;/strong&gt; Claude Code fires shell commands on events like &lt;code&gt;PreToolUse&lt;/code&gt; and &lt;code&gt;PostToolUse&lt;/code&gt;. A &lt;code&gt;PreToolUse&lt;/code&gt; hook reads the tool name and arguments and returns allow or deny; the model never sees the decision. The pattern handles concrete bans the prompt cannot reliably enforce: blocking &lt;code&gt;Edit&lt;/code&gt; on &lt;code&gt;.env&lt;/code&gt; or &lt;code&gt;secrets/&lt;/code&gt;, blocking &lt;code&gt;Bash&lt;/code&gt; against regexes like &lt;code&gt;rm -rf&lt;/code&gt; or &lt;code&gt;DROP TABLE&lt;/code&gt;, blocking &lt;code&gt;Write&lt;/code&gt; to migration directories outside an explicit unlock. Codex&amp;rsquo;s approvals primitive is a narrower version of the same idea, and some harnesses expose nothing comparable, in which case this layer has to be built outside the harness.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Wire-level filtering between the agent and the database.&lt;/strong&gt; A SQL-aware proxy in front of the database parses every statement and blocks denylist matches: &lt;code&gt;DROP&lt;/code&gt;, &lt;code&gt;TRUNCATE&lt;/code&gt;, unqualified &lt;code&gt;DELETE&lt;/code&gt;, schema-modifying DDL outside an unlock window, queries that touch tables the agent&amp;rsquo;s role has no business reading. ProxySQL with query rules, pgBouncer with extensions, and custom proxies all sit here; commercial SQL-firewall products exist but the open-source space is thin. The same pattern applies one layer up: an MCP server or RPC layer you control exposes only validated operations rather than passing arbitrary SQL through. The agent never speaks raw SQL to production. It speaks to an interface that decides what reaches the wire.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Structured logs with prompt provenance.&lt;/strong&gt; Every tool call captured: input prompt, tool name, arguments, response, timestamp, agent identity. A pre-AI audit log captured the SQL and treated it as sufficient. The AI-era version captures the reasoning context that produced it, because the SQL alone is incomplete in any incident review. Corruption can surface months after the prompt that produced it.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Behavioral monitoring with anomaly alerts.&lt;/strong&gt; Rate limits per agent identity. Baselines for normal call volume, tables touched, read and write volume. Alerts on threshold crossings: a 100x increase in &lt;code&gt;DELETE&lt;/code&gt; calls, a sudden read of a table the agent has never touched, a write to a schema outside its usual scope. Agents are non-human users with their own behavioral baselines, and the reference frame is anomaly detection on user accounts in security tooling.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;None of these lives in the context window, which means the agent cannot route around them by being asked nicely. The credentials are checked by the database. The MCP allowlist is checked by the connection layer. The hooks run in separate processes. The proxy parses every statement before it reaches the database. The logs are written by the harness. The alerts are evaluated by a separate system. Every layer is enforced by something that does not sample from a probability distribution.&lt;/p&gt;
&lt;p&gt;Each layer is engineering work. On a small enough deployment, the cost exceeds the cost of an incident.&lt;/p&gt;
&lt;h2 id="when-this-doesnt-apply"&gt;When this doesn&amp;rsquo;t apply
&lt;/h2&gt;&lt;p&gt;Read-only agents, with a caveat. Analytics, query, chat. A read-only role at the database is the layer for write damage, but reads are not free: every row the agent fetches is sent to the model provider as part of the next prompt. A table holding API keys, customer PII, or internal hostnames is not safe to expose to a third-party-hosted model just because the agent cannot write. Either the role&amp;rsquo;s grants exclude those tables, a wire-level filter strips sensitive columns, or the agent runs against a sanitized snapshot.&lt;/p&gt;
&lt;p&gt;Toy environments. Playgrounds, scratch databases, demos. The whole point is that the agent can break things; guardrails are friction.&lt;/p&gt;
&lt;p&gt;Single-operator small teams where the agent is the operator&amp;rsquo;s autocomplete. The operator is the verification layer. The agent&amp;rsquo;s permissions are intentionally equivalent to the operator&amp;rsquo;s.&lt;/p&gt;
&lt;p&gt;Everyone else needs all six. Agents holding production credentials. Agents working against shared infrastructure. Agents wired into CI/CD. Agents reading and writing customer data.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;None of these layers is novel for staff engineers. Scoped credentials, vetted tool surfaces, request interception at every layer, audit logs, anomaly detection. Every one is standard practice for any system that touches production, except they tend to be applied to humans and to other software. The shift the AI era forces is treating the agent as a separate principal that needs its own version, and treating the prompt as something other than a security control.&lt;/p&gt;</description></item><item><title>Letting AI Manage Your Indexes: the System and Guardrails the SME Has to Build</title><link>https://explainanalyze.com/p/letting-ai-manage-your-indexes-the-system-and-guardrails-the-sme-has-to-build/</link><pubDate>Wed, 29 Apr 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/letting-ai-manage-your-indexes-the-system-and-guardrails-the-sme-has-to-build/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Letting AI Manage Your Indexes: the System and Guardrails the SME Has to Build" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;AI can propose and ship index changes against a database where the SME has built two things: a context system (comments on indexes, recorded history, workload evidence surfaced into the prompt) and a guardrails layer (performance regression tests, catalog-redundancy checks, drop-safety rules, post-deploy monitors) that catches the corruption-floor errors any LLM produces. Without both, the loop collapses into &amp;ldquo;ask the assistant to fix the slow query,&amp;rdquo; and the index set grows monotonically because the model has neither memory nor visibility into what already exists.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The dashboard is slow. An engineer pastes the query into the assistant and gets back a &lt;code&gt;CREATE INDEX&lt;/code&gt; on three columns from the &lt;code&gt;WHERE&lt;/code&gt; clause. The query drops from 800 ms to 12 ms. Ticket closed. Three weeks later a different engineer files a similar ticket against a sibling query on the same table. Same flow, different index, same satisfying speedup. Six months and a hundred sessions later, the &lt;code&gt;orders&lt;/code&gt; table carries fourteen secondary indexes. &lt;code&gt;pg_stat_user_indexes&lt;/code&gt; reports &lt;code&gt;idx_scan = 0&lt;/code&gt; on eight of them. Three are strict prefixes of larger composite indexes that already cover the same predicate. The table&amp;rsquo;s index volume now exceeds its heap volume. p99 &lt;code&gt;INSERT&lt;/code&gt; latency has drifted from 9 ms to 31 ms over the same period, and no single deployment is responsible. Nobody added more than one index. Everyone added one.&lt;/p&gt;
&lt;p&gt;The obvious response is &amp;ldquo;the model is bad at this, don&amp;rsquo;t let it touch indexes.&amp;rdquo; That&amp;rsquo;s half right. Any LLM is bound by the &lt;a class="link" href="https://explainanalyze.com/p/corruption-is-a-feature-not-a-bug-why-llms-corrupt-by-design/" &gt;corruption floor&lt;/a&gt;; any proposal can quietly miss a constraint and ship a plausible-looking wrong answer, which is exactly what happens above when prefix-redundant indexes get created because the model can&amp;rsquo;t see the existing list. The model is the wrong variable to focus on. What does fix the loop is two pieces of work the SME owns: surface the context the model needs to make a grounded proposal, and build the guardrails that catch the residual errors before they ship. With both, AI does the bookkeeping a careful human would do, faster. With neither, the dashboard scenario above is the steady-state behavior.&lt;/p&gt;
&lt;h2 id="why-the-catalog-isnt-enough"&gt;Why the catalog isn&amp;rsquo;t enough
&lt;/h2&gt;&lt;p&gt;Indexes are a workload property. The catalog is a schema property. The mismatch is what every AI-driven index mistake comes from when nothing has been built to bridge it.&lt;/p&gt;
&lt;p&gt;A schema describes columns, types, and constraints. It says nothing about how the table is read, in what proportions, with what selectivity, or how often each predicate fires under production load. The two pieces of evidence that matter most for any index decision live entirely outside the catalog: the workload itself (slow-query log, &lt;code&gt;pg_stat_statements&lt;/code&gt;, the application&amp;rsquo;s actual query mix) and the planner&amp;rsquo;s recorded behavior (&lt;code&gt;pg_stat_user_indexes&lt;/code&gt;, &lt;code&gt;pg_stat_user_tables&lt;/code&gt;). An assistant reading the catalog sees neither. It sees the schema, the one query in the prompt, and a vague sense from training data of what indexes &amp;ldquo;tend to&amp;rdquo; exist on tables that look like this one.&lt;/p&gt;
&lt;p&gt;That gap explains the failure modes. Without the existing index list, the assistant proposes &lt;code&gt;(customer_id, created_at)&lt;/code&gt; when &lt;code&gt;(customer_id, status, created_at)&lt;/code&gt; already exists and serves the same predicate as a left-prefix match. Without selectivity statistics, the assistant orders composite columns by the order they appeared in the &lt;code&gt;WHERE&lt;/code&gt; clause, producing indexes whose leading column has 4 distinct values across 50M rows. Without the write/read ratio, every proposal is implicitly priced as free on the write path.&lt;/p&gt;
&lt;p&gt;Each session also starts fresh. There&amp;rsquo;s no continuity between the assistant that proposed &lt;code&gt;idx_orders_status_created&lt;/code&gt; last quarter and the one being asked the same question today. A reviewer six months ago tried that exact index, found the planner ignored it because of correlated columns, and removed it. The next session has no record of any of that and proposes it again.&lt;/p&gt;
&lt;p&gt;The lifecycle is asymmetric. AI is asked to make slow queries faster, a question that resolves with a &lt;code&gt;CREATE&lt;/code&gt;. AI is rarely asked to make the index set smaller, because nobody files a ticket for &amp;ldquo;we have too many indexes&amp;rdquo; until something is on fire. Every interaction nudges the count up; nothing in the loop nudges it back down.&lt;/p&gt;
&lt;p&gt;All of these gaps have the same shape: information that exists somewhere outside the catalog, and that the assistant has no path to unless something puts it there.&lt;/p&gt;
&lt;h2 id="what-every-new-index-actually-costs"&gt;What every new index actually costs
&lt;/h2&gt;&lt;p&gt;The default mental model is &amp;ldquo;an index makes reads faster, what&amp;rsquo;s the harm.&amp;rdquo; The harm is real and shows up across every layer the database touches.&lt;/p&gt;
&lt;p&gt;Every &lt;code&gt;INSERT&lt;/code&gt;, every &lt;code&gt;UPDATE&lt;/code&gt; on an indexed column, and every &lt;code&gt;DELETE&lt;/code&gt; updates every relevant index. A row written to a table with nine indexes is nine extra B-tree descents, nine page pins, nine potential page splits, and nine WAL records. The per-row write cost grows roughly linearly with the index count, and an &lt;code&gt;UPDATE&lt;/code&gt; that migrates the row touches every index when HOT can&amp;rsquo;t apply.&lt;/p&gt;
&lt;p&gt;WAL volume is what replicas consume, so the same cost replays on every standby and shows up as replication lag under load. A write-heavy workload with a redundant index set can saturate the replication channel before it saturates the primary&amp;rsquo;s local disk, and the failure mode reads as &amp;ldquo;the replicas are falling behind&amp;rdquo; rather than &amp;ldquo;we have too many indexes.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Indexes occupy buffer-pool pages that would otherwise hold hot heap data. A table with twice the index volume has roughly half the cache headroom for the heap. Backup size, restore time, and vacuum I/O all scale with total index volume, not heap volume. On most production systems, index volume already exceeds heap volume.&lt;/p&gt;
&lt;p&gt;There&amp;rsquo;s also a hidden second-order effect that the local optimization framing misses. Index choice changes the planner&amp;rsquo;s decisions for other queries. An index that helps query A by a measured 50 ms can shift the plan for query B onto a worse path costing 500 ms on a code path nobody&amp;rsquo;s currently watching. The session adding index A has no visibility into this. The regression surfaces a week later as &amp;ldquo;query B got slower,&amp;rdquo; the on-call engineer reaches for the assistant, and another index gets proposed for query B. The cycle repeats.&lt;/p&gt;
&lt;p&gt;Every new index is a permanent write tax paid on every transaction, in exchange for a read benefit on a subset of queries. The math only works when the read benefit is real and large enough to matter, the index is actually used by the planner under production statistics, and no existing index could have served the workload through extension or column reordering. Establishing those three points is exactly what the system around the assistant exists to do.&lt;/p&gt;
&lt;h2 id="most-slow-queries-arent-index-problems"&gt;Most slow queries aren&amp;rsquo;t index problems
&lt;/h2&gt;&lt;p&gt;Before the system is reached for, the framing the assistant defaults to is &amp;ldquo;slow query → CREATE INDEX.&amp;rdquo; That collapses a much larger decision tree into the move with the highest permanent cost. Four cheaper interventions usually exist, and at least one of them resolves the slowness without adding anything to the catalog.&lt;/p&gt;
&lt;p&gt;The query itself can be wrong. A predicate wrapped in a function (&lt;code&gt;WHERE LOWER(email) = ?&lt;/code&gt;, &lt;code&gt;WHERE DATE(created_at) = ?&lt;/code&gt;) is non-sargable and won&amp;rsquo;t use any regular index, so adding one accomplishes nothing. The fix is rewriting the predicate or fixing the column&amp;rsquo;s collation. &lt;a class="link" href="https://explainanalyze.com/p/non-sargable-predicates-how-a-function-in-where-kills-your-index/" &gt;Non-SARGable predicates&lt;/a&gt; covers the catalog. The same shape applies to implicit type casts on join columns, &lt;code&gt;OR&lt;/code&gt; predicates that defeat composite indexes, and &lt;code&gt;OFFSET&lt;/code&gt;-based pagination that gets quadratically slower as the offset grows.&lt;/p&gt;
&lt;p&gt;The application can change access pattern. List views that paginate with &lt;code&gt;LIMIT/OFFSET&lt;/code&gt; past page 50 belong on keyset pagination, where the client passes the last seen &lt;code&gt;(created_at, id)&lt;/code&gt; tuple and the query becomes a sargable range scan against an existing index. Sorting that the database is doing on a non-indexed column for a result set the client only ever reads twenty rows of can move client-side. Aggregations that fire on every page load can be cached or pre-computed by the application, removing the read pressure entirely rather than indexing around it. The pattern across all three: the slowness is a property of how the application is asking, not of what the database has indexed.&lt;/p&gt;
&lt;p&gt;The statistics can be stale. The planner can have the right index already and refuse to use it because &lt;code&gt;pg_stats&lt;/code&gt; thinks the predicate matches 80% of the table when it actually matches 0.1%. &lt;code&gt;ANALYZE&lt;/code&gt; is the first move on any &amp;ldquo;the planner won&amp;rsquo;t use the index&amp;rdquo; complaint, and on correlated columns the fix is &lt;code&gt;CREATE STATISTICS&lt;/code&gt; (Postgres) or extended histograms (MySQL), not another index.&lt;/p&gt;
&lt;p&gt;The schema can be the actual problem. A JSON column the workload filters on every read is paying the JSON-extract cost on every row no matter what indexes are added on the side; the durable fix is promoting the queried keys to typed or generated columns. A &lt;code&gt;VARCHAR&lt;/code&gt; column carrying numeric IDs forces an implicit cast on every lookup that no index can rescue. A polymorphic &lt;code&gt;resource_id&lt;/code&gt; column whose target depends on a sibling discriminator can&amp;rsquo;t be indexed in a way the planner uses for the conditional join the application actually wants.&lt;/p&gt;
&lt;p&gt;The assistant&amp;rsquo;s default skips all four because the catalog doesn&amp;rsquo;t surface any of them. A model prompted with the diagnostic ladder explicitly will work through it; a model prompted with &amp;ldquo;this query is slow, what should we do&amp;rdquo; will reach for the index. The difference is whether the prompt was constructed by a system that knows the ladder exists.&lt;/p&gt;
&lt;h2 id="what-the-sme-builds-context"&gt;What the SME builds: context
&lt;/h2&gt;&lt;p&gt;The first half of the SME&amp;rsquo;s work is the context system: what the model sees in the prompt, and what persists between sessions. Five surfaces, each closing a specific gap the catalog leaves open.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Each index has a documented purpose.&lt;/strong&gt; PostgreSQL supports &lt;code&gt;COMMENT ON INDEX&lt;/code&gt;; MySQL supports an &lt;code&gt;INDEX ... COMMENT&lt;/code&gt; clause on creation. Use either, and put a one-line description of which query the index serves and why the column order is what it is. Naming conventions carry the same load: &lt;code&gt;idx_orders_dashboard_list_v2&lt;/code&gt; is more legible than &lt;code&gt;idx_orders_status_created_customer&lt;/code&gt;. Both surfaces live in &lt;code&gt;information_schema&lt;/code&gt;, so any tool reading the catalog picks them up.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;History is persisted somewhere queryable.&lt;/strong&gt; A migration log, a &lt;code&gt;docs/indexes/&amp;lt;table&amp;gt;.md&lt;/code&gt; file, or a comment block on the table itself, recording what was tried and dropped. &amp;ldquo;We tried &lt;code&gt;(status, created_at)&lt;/code&gt; in 2025-Q3, the planner ignored it because of correlated columns, removed in migration 0142&amp;rdquo; is the cheapest way to keep the next session (any session, six months from now) from proposing the same dead end.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Workload evidence goes into the prompt.&lt;/strong&gt; A slow-query log entry, an &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; against representative data, the current &lt;code&gt;pg_stat_statements&lt;/code&gt; count for the query, and a snapshot of &lt;code&gt;pg_stat_user_indexes&lt;/code&gt; for the table. The artifacts ground the proposal in real workload rather than a hypothetical, and they&amp;rsquo;re exactly the evidence the model can use if it&amp;rsquo;s handed in.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The existing-index dump goes into the prompt.&lt;/strong&gt; Before any proposal, dump the current indexes on the table and the indexes the planner has been using for nearby queries. The dump catches redundancy (proposed index is a left-prefix of an existing composite) and supersession (the existing composite would serve the new query if its column order were tweaked or an &lt;code&gt;INCLUDE&lt;/code&gt; clause were added). A model handed the dump routinely catches this; a model not handed it routinely misses it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hypothetical indexes go first.&lt;/strong&gt; PostgreSQL&amp;rsquo;s &lt;a class="link" href="https://github.com/HypoPG/hypopg" target="_blank" rel="noopener"
 &gt;HypoPG&lt;/a&gt; creates a fake index, the planner costs it as if it existed, and &lt;code&gt;EXPLAIN&lt;/code&gt; reports whether it would be used. The cost is zero, and the signal is whether the proposed index would actually change the plan under current statistics. MySQL has no direct equivalent; the discipline there is to validate the proposal against a recent production snapshot before merging.&lt;/p&gt;
&lt;h2 id="what-the-sme-builds-guardrails"&gt;What the SME builds: guardrails
&lt;/h2&gt;&lt;p&gt;The context system gets the model to a grounded proposal. The guardrails catch what the model still gets wrong, the same way &lt;a class="link" href="https://explainanalyze.com/p/testing-your-database-part-1-why-ai-made-it-mandatory/" &gt;tests on database code&lt;/a&gt; catch regressions a thoughtful human can produce.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Performance regression tests on hot queries.&lt;/strong&gt; For each query the team cares about, write a test that runs &lt;code&gt;EXPLAIN&lt;/code&gt; and asserts the plan uses the expected index, or stays under a row-count budget, or doesn&amp;rsquo;t fall back to a sequential scan. Run on every migration. The test catches &amp;ldquo;AI added an index that shifted the planner onto a worse path for query B&amp;rdquo; - a class of failure that&amp;rsquo;s invisible at code review time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Catalog-redundancy linter.&lt;/strong&gt; Block CI when a migration adds an index that&amp;rsquo;s a strict left-prefix of an existing composite, or a single-column index that duplicates a leading column of one. The check is mechanical, the rule fits in a small SQL query against &lt;code&gt;pg_index&lt;/code&gt;, and it catches the most common AI failure mode without any human in the loop.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Drop-safety check.&lt;/strong&gt; Before any &lt;code&gt;DROP INDEX&lt;/code&gt; lands, the check confirms &lt;code&gt;idx_scan&lt;/code&gt; has been zero for N days and the index&amp;rsquo;s comment doesn&amp;rsquo;t flag it as kept for a known non-daily workload. The check fails loud and the migration doesn&amp;rsquo;t run. This is what the comment-on-index discipline above pays back: the comment is the data the check reads.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Lock-budget guards.&lt;/strong&gt; Block DDL that would take &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; on tables tagged as hot, unless the migration uses &lt;code&gt;CONCURRENTLY&lt;/code&gt; (Postgres) or the equivalent online algorithm (MySQL). Catches &amp;ldquo;AI proposed &lt;code&gt;CREATE INDEX&lt;/code&gt; without &lt;code&gt;CONCURRENTLY&lt;/code&gt; on a 500M-row table&amp;rdquo; before it reaches production.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Continuous index-health monitoring.&lt;/strong&gt; Workloads shift constantly: queries get removed from the application, access patterns change, table sizes grow past the point where a once-useful index stops mattering, a deploy reroutes the planner to a different index. None of those surface in the catalog. A long-running monitor watches &lt;code&gt;pg_stat_user_indexes&lt;/code&gt;, &lt;code&gt;pg_stat_statements&lt;/code&gt;, and write-path latency, and fires when a previously-hot index&amp;rsquo;s scan count flatlines, when its write-cost-to-read-benefit ratio crosses a threshold, or when the planner walks away from an index that hot queries used to depend on. Each is a separate alert. The corruption-floor failure that survives every other check usually shows up here first, as a metric change.&lt;/p&gt;
&lt;p&gt;When a monitor fires, the alert is a context-gathering job the model is well-suited for. The LLM pulls the index&amp;rsquo;s comment, the migration history, the documented purpose, recent &lt;code&gt;pg_stat_statements&lt;/code&gt; data, and any related queries from across the catalog, and produces a summary: what the index was meant to serve, what the data shows about its current usage, and a proposed disposition. The SME reads the summary and makes the call. The drop-safety check above is the floor underneath the call: even if the SME approves the drop, the migration doesn&amp;rsquo;t run if the comment flags a known non-daily workload.&lt;/p&gt;
&lt;p&gt;The honest trade-off is that this isn&amp;rsquo;t free. Building the context surface and the guardrails up front is real work, and on a small or short-lived database it&amp;rsquo;s overkill. The work pays back when the system is large enough that no human reliably has the whole picture, the workload changes faster than any one person can track, and AI is being used in the loop. At that scale, every component above is cheaper than the failure it prevents.&lt;/p&gt;
&lt;h2 id="when-the-discipline-isnt-worth-the-friction"&gt;When the discipline isn&amp;rsquo;t worth the friction
&lt;/h2&gt;&lt;p&gt;The system earns its cost on production OLTP databases with multiple writers, sustained traffic, and a year or more of accumulated drift ahead. It&amp;rsquo;s overkill in three places.&lt;/p&gt;
&lt;p&gt;OLAP and columnar workloads work differently. ClickHouse, DuckDB, and BigQuery don&amp;rsquo;t carry the same per-row write tax, and the article&amp;rsquo;s mental model doesn&amp;rsquo;t transfer. Very small tables don&amp;rsquo;t repay the discipline either; a 5,000-row admin table with a dozen lookup indexes is using a few megabytes of cache and adding microseconds to writes that aren&amp;rsquo;t on any hot path. Single-writer workloads with a small, enumerable query set are the third case: a reporting database serving twenty known queries from one ingestion job has an index set that can be designed up front and reviewed by hand. The system pays for itself when the query mix is large enough that no human reliably has the whole picture.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;The recurring pattern across &lt;a class="link" href="https://explainanalyze.com/p/what-ai-gets-wrong-about-your-database/" &gt;self-documenting schemas&lt;/a&gt;, &lt;a class="link" href="https://explainanalyze.com/p/foreign-keys-are-not-optional/" &gt;foreign keys&lt;/a&gt;, and &lt;a class="link" href="https://explainanalyze.com/p/comment-your-schema/" &gt;column comments&lt;/a&gt; is that the work of making AI useful on a production database is the same work that makes the database legible to any reader. Indexes are the same shape one level up. An index whose comment explains its purpose, whose history is recorded, and whose usage shows up in a regression test that runs on every migration is an index a human or an assistant can reason about safely. An index named &lt;code&gt;idx_orders_status_created_customer_3&lt;/code&gt; with no comment, no recorded history, and no test asserting which query depends on it is an index neither can reason about, and the failure mode is the same in both cases.&lt;/p&gt;
&lt;p&gt;The SME&amp;rsquo;s role in an AI-assisted database is the work AI doesn&amp;rsquo;t do: build the context surface, and build the guardrails that catch the corruption-floor errors the model still produces against the best context. The model proposes. The system the SME built is what makes those proposals safe to ship.&lt;/p&gt;</description></item><item><title>Corruption Is a Feature, Not a Bug: Why LLMs Corrupt by Design</title><link>https://explainanalyze.com/p/corruption-is-a-feature-not-a-bug-why-llms-corrupt-by-design/</link><pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/corruption-is-a-feature-not-a-bug-why-llms-corrupt-by-design/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Corruption Is a Feature, Not a Bug: Why LLMs Corrupt by Design" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;Frontier LLMs corrupt at least 25% of delegated multi-step document work in lab conditions. The rate rises with document size and turn count, and tool use doesn&amp;rsquo;t help. Corruption is a property of the architecture, not a defect to be patched, and the only thing that closes the gap is a best-in-class domain expert at every checkpoint.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Microsoft Research has a number on it. &lt;a class="link" href="https://arxiv.org/abs/2604.15597" target="_blank" rel="noopener"
 &gt;Laban, Schnabel, and Neville&amp;rsquo;s &lt;em&gt;LLMs Corrupt Your Documents When You Delegate&lt;/em&gt;&lt;/a&gt; (arxiv 2604.15597, April 2026) ran the DELEGATE-52 benchmark across 52 professional domains (coding, crystallography, music notation, professional writing) against 19 frontier LLMs including Claude 4.6 Opus, GPT 5.4, and Gemini 3.1 Pro. Average corruption: 25% of document content by the end of long workflows. Tool use doesn&amp;rsquo;t fix it. Agentic harnesses don&amp;rsquo;t fix it. Larger documents and longer interactions make it worse, not better. The number doesn&amp;rsquo;t depend on which frontier model you pick.&lt;/p&gt;
&lt;h2 id="the-25-is-a-floor-not-a-ceiling"&gt;The 25% is a floor, not a ceiling
&lt;/h2&gt;&lt;p&gt;The benchmark is a controlled lab measurement against curated tasks with known ground truth. Real production has every reality catalogued in &lt;a class="link" href="https://explainanalyze.com/p/what-ai-gets-wrong-about-your-database/" &gt;What AI Gets Wrong About Your Database&lt;/a&gt;: undocumented conventions, polysemic columns, four-format date strings, JSON-as-schema, business logic in tribal knowledge, ten-year-old codebases with three &amp;ldquo;current&amp;rdquo; patterns for the same operation. The model in production reads from that impoverished signal, and the rate multiplies. The 25% is what you get on a good day on clean data. Production is not a good day.&lt;/p&gt;
&lt;h2 id="the-version-doesnt-matter---corruption-is-a-feature"&gt;The version doesn&amp;rsquo;t matter - corruption is a feature
&lt;/h2&gt;&lt;p&gt;Claude Opus 4.7 is the latest as of writing. DELEGATE-52 measured 4.6, GPT 5.4, Gemini 3.1 Pro. The next generation will measure at the same floor. Not because the labs aren&amp;rsquo;t trying (they are) but because the corruption isn&amp;rsquo;t a defect to patch. It&amp;rsquo;s the property you bought when you bought &amp;ldquo;language model.&amp;rdquo; The same mechanism that makes the model useful (generalizing from a training distribution to plausible novel output) is the one that makes it corrupt your document (generalizing from a training distribution to plausible novel output that doesn&amp;rsquo;t match your specific facts). You can&amp;rsquo;t fix one without losing the other.&lt;/p&gt;
&lt;p&gt;The framing this post takes is that LLMs are a probability machine first and an intelligence-shaped artifact second. That&amp;rsquo;s a stance, not an uncontested fact, but the engineering implications of the post all follow from taking the first reading seriously.&lt;/p&gt;
&lt;h2 id="the-obvious-fixes-that-dont-work"&gt;The obvious fixes that don&amp;rsquo;t work
&lt;/h2&gt;&lt;p&gt;The reflex when the rate is 25% is to reach for the things that usually fix software defects. None of them touch the floor:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&amp;ldquo;Use a better model.&amp;rdquo;&lt;/strong&gt; The benchmark already measured the frontier. Same rate.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&amp;ldquo;Add tools, RAG, fine-tuning.&amp;rdquo;&lt;/strong&gt; Tool use doesn&amp;rsquo;t change the rate. RAG narrows the prior, but the same sampling mechanism draws from it. Fine-tuning shifts the distribution; it doesn&amp;rsquo;t add deterministic constraints.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&amp;ldquo;Add agent self-verification.&amp;rdquo;&lt;/strong&gt; The verifier is the same architecture reading the same training distribution as the generator. It will ratify the corruption with the same confidence the generator produced it with.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&amp;ldquo;Add more context.&amp;rdquo;&lt;/strong&gt; &lt;a class="link" href="https://explainanalyze.com/p/what-ai-gets-wrong-about-your-database/" &gt;What AI Gets Wrong About Your Database&lt;/a&gt; already covered this. More context lowers the rate, doesn&amp;rsquo;t drive it to zero. The hallucination floor is structural.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These aren&amp;rsquo;t bad ideas. They lower the rate from terrible to bad. They don&amp;rsquo;t make delegation safe.&lt;/p&gt;
&lt;h2 id="why-this-is-structural-the-mechanism"&gt;Why this is structural: the mechanism
&lt;/h2&gt;&lt;p&gt;The mechanism is worth understanding because it&amp;rsquo;s what tells you why &amp;ldquo;use a better model&amp;rdquo; doesn&amp;rsquo;t move the floor.&lt;/p&gt;
&lt;p&gt;Start with embeddings. The model &amp;ldquo;understands&amp;rdquo; &lt;code&gt;users.deleted_at&lt;/code&gt; as a vector position adjacent to other &lt;code&gt;deleted_at&lt;/code&gt; columns it saw during training. There is no concept of your soft-delete convention, your tenant filter, the incident your team had last quarter, or the rule you wrote into the catalog comment two months ago. The vector is a fingerprint of what tokens like that one tend to appear next to in a billion training documents, not a fact set the model can check against.&lt;/p&gt;
&lt;p&gt;Attention works the same way. Each output token is a weighted blend of every other token in context, with the weights being learned similarity scores. The model isn&amp;rsquo;t looking up &amp;ldquo;the right answer for this schema.&amp;rdquo; It&amp;rsquo;s computing a weighted average of what tokens like this one tend to be followed by tokens like that one in its training distribution. Correctness is the special case where the distribution happens to be sharply peaked on the correct token.&lt;/p&gt;
&lt;p&gt;Generation pulls all of that into a sampling step. Every token is a draw from a probability distribution. When training data is dense and consistent for the topic, the distribution is sharp; but a sharp distribution is reliable token-relationship probability, not knowledge of the answer. The tokens in that region of the data point reliably to a particular continuation, and the relationships are co-occurrence statistics with no internal check against reality. When the underlying relationship happens to match the world, the guess looks right. When it doesn&amp;rsquo;t (a popular misconception in the training corpus, an outdated convention, a pattern typical of training data but not of your specific case) the guess is confidently wrong with the same calibration. When training data is sparse, contradictory, or local to your codebase, the distribution flattens, the guess gets noisier, and the calibration of the model&amp;rsquo;s confidence stays the same. Nothing in the architecture says &amp;ldquo;I don&amp;rsquo;t know.&amp;rdquo; It says &amp;ldquo;this token has the highest probability among my distribution,&amp;rdquo; even when the distribution is barely above random, or sharp on a relationship that doesn&amp;rsquo;t hold for your case.&lt;/p&gt;
&lt;p&gt;To collapse those three steps into the operation that actually runs: every token is a vector, a position in a high-dimensional space, typically thousands of dimensions (4,096 in some models, 12,288 in others). Similarity between two tokens is the dot product (or cosine distance) of their vectors. Attention computes its weights by taking those similarity scores between the current position and every prior token in context, then softmax-normalizing. The probability of the next token is the dot product of the model&amp;rsquo;s predicted direction against every candidate token&amp;rsquo;s vector in the vocabulary, divided through a softmax to produce a distribution. Every probability you read out of the model is a distance computation between vectors in that high-dimensional space. Understanding is position. Probability is geometric proximity. There&amp;rsquo;s no step in the pipeline where knowledge enters; only matrix multiplications and a normalizing function.&lt;/p&gt;
&lt;p&gt;DELEGATE-52&amp;rsquo;s 25% is the rate at which the distribution flattened across 52 domains&amp;rsquo; edge cases and the sampling collapsed to plausible-sounding hallucination. The confidence reading stayed identical to when the model was right. This is &lt;a class="link" href="https://explainanalyze.com/p/testing-your-database-part-1-why-ai-made-it-mandatory/" &gt;Part 1&amp;rsquo;s &amp;ldquo;confidence is anti-signal&amp;rdquo;&lt;/a&gt; restated at the architecture level: confidence and correctness are produced by different mechanisms, neither tied to the other.&lt;/p&gt;
&lt;h2 id="why-best-in-class-sme-is-the-load-bearing-safeguard"&gt;Why best-in-class SME is the load-bearing safeguard
&lt;/h2&gt;&lt;p&gt;Humans don&amp;rsquo;t operate this way. A crystallographer knows the unit cell parameters have to satisfy specific symmetry constraints, not because she&amp;rsquo;s seen a million similar structures, but because the constraints follow from a small set of facts she can verify against. A senior database engineer knows the soft-delete convention because she wrote it. A composer knows the chord progression doesn&amp;rsquo;t resolve because the leading tone wasn&amp;rsquo;t raised. That knowledge is symbolic, propositional, traceable to evidence the human can produce on demand. It isn&amp;rsquo;t a probability distribution.&lt;/p&gt;
&lt;p&gt;That is the gap an SME closes. Not &amp;ldquo;any reviewer with a checklist&amp;rdquo;; a generalist reviewer can&amp;rsquo;t tell when the model has silently corrupted the symmetry constraints, the soft-delete predicate, or the leading-tone rule. The corruption looks plausible because the model&amp;rsquo;s job is producing plausible output. Catching it requires someone who holds the actual constraints in their head and can check the model&amp;rsquo;s output against them. The cheaper the SME, the more corruption ships.&lt;/p&gt;
&lt;p&gt;The labor-market reading of this is already visible. &lt;a class="link" href="https://www.ibm.com/think/news/entry-level-roles-get-reset-ai" target="_blank" rel="noopener"
 &gt;IBM announced in February 2026 it would triple US entry-level hiring&lt;/a&gt;, explicitly because the AI era hollowed out the rote tasks that used to fill junior roles and left the load-bearing work (judgment, customer interaction, oversight of automated systems) needing humans who grow into it. The pipeline argument is unforgiving: cut juniors to capture the AI-productivity dividend, save short-term, and starve the senior layer the next decade of work depends on. The companies treating today&amp;rsquo;s juniors as a long bet on the experts they&amp;rsquo;ll become are reading the architecture honestly. The ones cutting them to bank the LLM savings are paying down the pipeline their competitors are building.&lt;/p&gt;
&lt;p&gt;The supply side is tightening on both ends. The senior tier is retiring out (the engineers who built the soft-delete conventions, the schema histories, the production-incident memory) and that institutional knowledge isn&amp;rsquo;t transferring into a probability distribution any model can sample from. On the formation side, &lt;a class="link" href="https://www.anthropic.com/research/AI-assistance-coding-skills" target="_blank" rel="noopener"
 &gt;Anthropic&amp;rsquo;s own research shows engineers using AI assistance score 50% on comprehension quizzes about the code they shipped, against 67% for engineers writing the same code unaided&lt;/a&gt; - a 17-point gap. The skill-formation loop that turns juniors into SMEs over a decade (write, struggle, debug, internalize) gets shortcut by tooling that produces working code without the struggle. Companies that don&amp;rsquo;t actively design against both effects get the worst of three pressures: SMEs retiring, juniors not deepening, and an architecture that has no internal substitute for either.&lt;/p&gt;
&lt;p&gt;The unintuitive recommendation that follows: don&amp;rsquo;t fire the humans you have. The SME labor market will tighten faster than LLM tooling can replace what SMEs do. Supply is shrinking on both ends, the architecture has no substitute, and today&amp;rsquo;s senior engineer is cheap relative to their replacement cost in three to five years. Companies banking the LLM productivity dividend by cutting senior staff are trading short-term margin for a much steeper rehiring bill against a constrained future market. The math will look obvious in retrospect. It doesn&amp;rsquo;t look obvious now because the LLM line item lands on the income statement before the SME-shortage bill arrives.&lt;/p&gt;
&lt;p&gt;This is why &amp;ldquo;best-in-class&amp;rdquo; is load-bearing in the title. A junior with a checklist runs the same architecture-level pattern-matching the model does. Recognizes things that look right, doesn&amp;rsquo;t catch silent semantic drift. A top-of-class SME has the constraint set internalized to the point that the wrong answer feels wrong, even when the surface looks correct. That feeling is the safeguard the architecture cannot provide.&lt;/p&gt;
&lt;h2 id="the-system"&gt;The system
&lt;/h2&gt;&lt;p&gt;Don&amp;rsquo;t delegate end-to-end. Decompose work into chunks small enough that an SME can verify each one in minutes. Checkpoint between chunks. Route each checkpoint to the SME whose domain it&amp;rsquo;s in. Treat every chunk as 25%-floor untrusted by default. Don&amp;rsquo;t trust agentic chains to self-verify (the verifier reads from the same training distribution as the generator). Don&amp;rsquo;t trust LLM-judge eval as a release gate (&lt;a class="link" href="https://explainanalyze.com/p/testing-your-database-part-2-what-to-test-and-how/" &gt;Part 2 of the testing series&lt;/a&gt; covered why; the architectural reason is in the mechanism above). The system is decomposition plus checkpoints plus SMEs, not a better model and not a better prompt.&lt;/p&gt;
&lt;p&gt;The cost is real. SMEs are expensive, the workflow is slower, and the temptation to skip checkpoints when the early ones look fine is constant. The cost of the alternative (silent 25%-floor corruption layered through a long workflow, surfaced six months later when the data has propagated past the recovery window) is much higher and structurally harder to detect. The math is the math the testing series already laid out: catching corruption pre-deployment is a fraction of the cost of finding it after.&lt;/p&gt;
&lt;h2 id="when-this-doesnt-apply"&gt;When this doesn&amp;rsquo;t apply
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Drafts you&amp;rsquo;ll discard.&lt;/strong&gt; Brainstorming, throwaway code, content the human will rewrite anyway. The model is generating a starting point, not delegated output.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The user is the SME.&lt;/strong&gt; A senior database engineer using AI to draft SQL she&amp;rsquo;ll review line-by-line is using the model as autocomplete, not as delegation. The 25% is irrelevant because she&amp;rsquo;s the verification layer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Low-stakes, recoverable work.&lt;/strong&gt; A typo in a personal email isn&amp;rsquo;t a 25% corruption event you need to system-design around.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bounded, well-trodden problems.&lt;/strong&gt; Generating boilerplate in a popular language with a well-documented framework is the dense-distribution sweet spot. The rate is much lower because the prior is sharp.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Proof-of-concept and rapid-feedback work.&lt;/strong&gt; &amp;ldquo;Does this idea work at all&amp;rdquo; needed in minutes. The 25% floor is the right trade because the output is a directional signal, not production code; the cost of being wrong is &amp;ldquo;we tried, didn&amp;rsquo;t pan out.&amp;rdquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The article is about the rest - production work where corruption is invisible, expensive to fix, and the team is treating the model as a co-author instead of a guess machine.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;Calling the model &amp;ldquo;intelligence&amp;rdquo; is the framing that gets engineers in trouble. Intelligence implies a knowing entity that holds facts, checks them against evidence, and tells you when it doesn&amp;rsquo;t know. The architecture has none of those properties. It has a learned distribution and a sampling procedure. The output is a guess every time, and the guess is well-calibrated only where the training data was dense and consistent - precisely not where your specific codebase, your specific schema, or your specific domain conventions live.&lt;/p&gt;
&lt;p&gt;The 25% floor is what that guarantees, in numbers. Versions don&amp;rsquo;t move it. Tools don&amp;rsquo;t move it. Bigger context doesn&amp;rsquo;t drive it to zero. The only thing that closes the gap between the architecture and the work is a human who knows the domain, checking the output against constraints the architecture can&amp;rsquo;t represent.&lt;/p&gt;
&lt;p&gt;Treat the model as a probability machine and the engineering decisions get easier. Decompose. Checkpoint. Put the best SME you have on each domain. Build the testing layer the way &lt;a class="link" href="https://explainanalyze.com/p/testing-your-database-part-1-why-ai-made-it-mandatory/" &gt;the testing series&lt;/a&gt; describes. Stop expecting the next model to fix it.&lt;/p&gt;</description></item><item><title>How Teams Actually Finish What They Start, Part V: The Sprint as a Working Set</title><link>https://explainanalyze.com/p/how-teams-actually-finish-what-they-start-part-v-the-sprint-as-a-working-set/</link><pubDate>Tue, 21 Apr 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/how-teams-actually-finish-what-they-start-part-v-the-sprint-as-a-working-set/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post How Teams Actually Finish What They Start, Part V: The Sprint as a Working Set" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;For teams whose week is shaped by inbound work, the sprint should hold only what is being worked on now plus what gets pulled next. No forward estimates, no velocity commitments. Priority lives in labels; the team pulls from the labeled backlog as in-flight work completes.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Tuesday at 2pm. Sprint planning. The team has been here for ninety minutes. Eighteen tickets on the board, points being argued about (was this a 5 or an 8 last quarter?). A senior engineer flags they have to leave for an interview at 3. The product manager wants to commit to 42 points so the velocity curve in the leadership deck stays smooth. Three operational tickets came in during the meeting itself. Nobody has touched code today. The sprint will start tomorrow with eighteen tickets the team has not properly looked at, plus the three that arrived during planning, plus whatever arrives over the next two weeks. The planning meeting was the work today.&lt;/p&gt;
&lt;h2 id="better-grooming-doesnt-fix-it"&gt;Better grooming doesn&amp;rsquo;t fix it
&lt;/h2&gt;&lt;p&gt;The standard fixes target the planning meeting: better grooming, T-shirt sizing instead of points, async estimation in Slack. Each saves twenty minutes and leaves the underlying mistake intact. The mistake is the sprint trying to be a committed plan for two weeks of work the team has not done yet, on a team whose two weeks are not predictable. No amount of grooming makes the unpredictable predictable. The fix is a sprint that admits it.&lt;/p&gt;
&lt;h2 id="the-sprint-as-a-working-set"&gt;The sprint as a working set
&lt;/h2&gt;&lt;p&gt;A different mode: the sprint holds only what is being worked on right now, plus the highest-priority items the team will pull next. That is it. The sprint stops being a two-week forecast or a velocity commitment. The board reflects reality rather than narrating it.&lt;/p&gt;
&lt;p&gt;The mechanics follow. The backlog holds everything, labeled by priority. The manager keeps the labels current, and high-priority items rise to the top of the filtered view. The sprint holds only tickets currently in progress plus immediate next pulls. Engineers pull from the labeled backlog into the sprint as their current work completes. There is no forward estimation at planning, because points are written post-hoc (see &lt;a class="link" href="https://explainanalyze.com/p/how-teams-actually-finish-what-they-start-part-iv-point-after-the-fact/" &gt;Part IV&lt;/a&gt;). Planning becomes a short check-in: the team confirms priorities, surfaces blockers, and returns to work.&lt;/p&gt;
&lt;p&gt;Engineers file their own tickets when they discover work. A bug found while shipping a feature. A refactor that surfaces during code review. A dependency that needs chasing. The IC who found it writes the ticket. &amp;ldquo;Someone will write this up later&amp;rdquo; becomes nobody, and the work disappears from the tracker without disappearing from reality. The team&amp;rsquo;s tracker has to hold the team&amp;rsquo;s actual work; if the work is not in the tracker, the work does not exist for planning purposes.&lt;/p&gt;
&lt;p&gt;The responder rotation absorbs incoming interruption tickets (&lt;a class="link" href="https://explainanalyze.com/p/how-teams-actually-finish-what-they-start-part-iii-a-working-responder-rotation/" &gt;Part III&lt;/a&gt;) so the sprint is not churned by every Slack message and every cross-team request. The sprint is what the team is building. The responder column is what arrives. The two queues stay separate, and the sprint stays small.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;The sprint is not a copy of the backlog&lt;/strong&gt;
 &lt;div&gt;The pressure to grow the sprint is constant. Leadership wants velocity numbers, the team wants to look ambitious, every new priority feels like it should land &amp;ldquo;in this sprint.&amp;rdquo; It should not. The sprint is what the team is doing now plus what they will pull next. If the sprint contains tickets nobody has looked at, the discipline has slipped, and the velocity that comes out the other side is fiction.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="in-the-tracker"&gt;In the tracker
&lt;/h2&gt;&lt;p&gt;Jira gives you priority fields, labels, components, ranks, epics, and themes. The working-set sprint needs three things from the tracker: a backlog the manager can prioritize, a way to see the labeled top of the backlog, and a sprint board that shows what the team is doing right now. The rest is decoration.&lt;/p&gt;
&lt;p&gt;A workable setup: priority lives on a single field or a single label, picked once and used consistently. A saved filter (JQL or board view) shows the labeled high-priority backlog, and that filter is the team&amp;rsquo;s entry point when their current ticket closes. The sprint board shows in-progress and next-up tickets only. Standups walk that board ticket by ticket. Estimation columns are optional; if used, they are filled in after the ticket closes, not before.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;Use one priority mechanism, not three&lt;/strong&gt;
 &lt;div&gt;Jira lets you mix priority field, labels, components, and rank order. Pick one. A label like &lt;code&gt;priority:p0&lt;/code&gt; works. So does the built-in priority field. Mixing them means engineers pull from one filter while the manager updates another, and the team works on the wrong tickets while the tracker says everything is fine.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="when-forward-sprints-work"&gt;When forward sprints work
&lt;/h2&gt;&lt;p&gt;A team with a stable, well-scoped backlog and predictable interruptions can run forward sprints with point commitments. Some maintenance teams have this. Some platform teams whose remit has been narrowed and frozen do too. For everyone else, the working-set sprint is the one that matches reality. Part IV&amp;rsquo;s measurement discipline is how the team finds out which side it is on; the working-set sprint is what the team does with the answer.&lt;/p&gt;
&lt;h2 id="what-changes"&gt;What changes
&lt;/h2&gt;&lt;p&gt;Sprint planning gets short. Velocity stops being a fiction the leadership deck has been running on. With the board reflecting actual work, the rest of the cadence gets honest too. Standups walk the board ticket by ticket and finish in fifteen minutes. Retros talk about what actually happened, not about the gap between estimate and reality. And reviews compare engineers on the same shapes of work, with the data Part IV produced. The tracker stops being a stage for the planning ritual and becomes a tool for getting work done.&lt;/p&gt;</description></item><item><title>How Teams Actually Finish What They Start, Part IV: Point After the Fact</title><link>https://explainanalyze.com/p/how-teams-actually-finish-what-they-start-part-iv-point-after-the-fact/</link><pubDate>Fri, 03 Apr 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/how-teams-actually-finish-what-they-start-part-iv-point-after-the-fact/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post How Teams Actually Finish What They Start, Part IV: Point After the Fact" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;Forward estimation breaks for any team whose week is shaped by work that arrives. The discipline that scales is to point after the fact: when the ticket closes, the person who did the work writes down what it took. Over a quarter the team has real data about where time actually goes.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Standup Monday. A 5-point ticket lands in the engineer&amp;rsquo;s column. Wednesday the engineer is still on it, two production fires deep, the original scope half-discovered. By Friday it ships. The retro looks at velocity. The 5 stays a 5, the team&amp;rsquo;s data says the engineer did 5 points of work, and the next sprint&amp;rsquo;s planning uses that data to size the next ticket of the same shape. The bug compounds.&lt;/p&gt;
&lt;h2 id="better-estimates-dont-fix-it"&gt;Better estimates don&amp;rsquo;t fix it
&lt;/h2&gt;&lt;p&gt;The obvious move is to estimate better: planning poker, three-point estimates, finer story points, more grooming up front. None of it works on a team whose week is shaped by inbound work. The variance is not in how the engineer reads the ticket. It is in what arrives between Monday and Friday. A ticket scoped honestly Monday gets eaten by an unrelated incident Wednesday. A 5-point ticket stays 5 points until the dependency the engineer didn&amp;rsquo;t know about turns it into 13. Forward estimation is trying to predict the team&amp;rsquo;s week, and the team&amp;rsquo;s week is not predictable.&lt;/p&gt;
&lt;h2 id="point-what-you-did"&gt;Point what you did
&lt;/h2&gt;&lt;p&gt;The fix is mechanical. When the ticket closes, the person who did the work writes down what it took. Over a quarter the team has real data: which categories of work consume the most time, which engineers carry which kinds of load, where the same shape of problem keeps eating a day each time. The cost is near zero (the ticket is closed; the person who did the work is sitting there). Bottlenecks surface fast: a category that always takes three times what its siblings take is a place to invest, and the data makes it visible without anyone having to argue for it.&lt;/p&gt;
&lt;p&gt;The rule has to be load-bearing in the workflow, not aspirational. A ticket cannot move to Done without a points value. Without that constraint, the data has gaps, and the gaps are not the random kind.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Half-pointed data is worse than no data&lt;/strong&gt;
 &lt;div&gt;Without a load-bearing constraint, easy tickets get pointed and hard tickets get closed in a rush. The long-tail work the team most needs to see disappears from the record. The team trusts the partial data anyway and reaches the wrong conclusions about its own capacity.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="what-the-data-enables"&gt;What the data enables
&lt;/h2&gt;&lt;p&gt;Operation-heavy teams can drop forward sprint commitments entirely. The manager sets priorities through labels on tickets and epics. The team pulls from the top of the labeled backlog as engineers free up. After-the-fact points accumulate over the quarter and give the team a real baseline: capacity, distribution, ticket-shape patterns. Forecasting becomes a quarter-long view rather than a sprint-long commitment.&lt;/p&gt;
&lt;p&gt;The same data benchmarks people. Recurring work converges in shape over a quarter. Responder rotations cluster around the same handful of incident types. Refactors pattern-match to a few common shapes. When ten engineers have closed the same kind of ticket, the spread of points across people becomes visible. Reviews stop being a debate about effort and become a comparison against work everyone has done. The conversation shifts from &amp;ldquo;I think Alice ships fast&amp;rdquo; to &amp;ldquo;Alice closes the same shape of ticket in 6 points where the team median is 8.&amp;rdquo;&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;Compare same shapes, not raw velocity&lt;/strong&gt;
 &lt;div&gt;The benchmark only works for like-for-like work. A frontend ticket and a database investigation are not on the same scale. Group tickets by shape (incident type, refactor pattern, investigation category) before comparing engineers, and ignore raw point totals across the team.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="when-forward-estimation-works"&gt;When forward estimation works
&lt;/h2&gt;&lt;p&gt;Forward estimation works when the team&amp;rsquo;s work is genuinely stable: same shape every week, predictable interruptions, no unscoped depth. Some maintenance teams have this. Most product teams and most operational teams do not. The point of measuring after the fact is to find out which side your team is actually on, and to design the cadence around the answer.&lt;/p&gt;
&lt;h2 id="what-changes-over-a-quarter"&gt;What changes over a quarter
&lt;/h2&gt;&lt;p&gt;A team that points after the fact has a quarter&amp;rsquo;s worth of evidence about itself: which work consumes time, who is faster on what, where the bottlenecks live. A team that points before the fact has a quarter&amp;rsquo;s worth of guesses, refined and re-litigated each sprint. Retros become data-driven instead of opinion-driven. Reviews compare engineers against the same shapes of work. And when the team asks for headcount, the ask is grounded in a category that has been swallowing unbudgeted time, not a hunch.&lt;/p&gt;</description></item><item><title>How Teams Actually Finish What They Start, Part III: A Working Responder Rotation</title><link>https://explainanalyze.com/p/how-teams-actually-finish-what-they-start-part-iii-a-working-responder-rotation/</link><pubDate>Tue, 17 Mar 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/how-teams-actually-finish-what-they-start-part-iii-a-working-responder-rotation/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post How Teams Actually Finish What They Start, Part III: A Working Responder Rotation" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;For teams whose week is operational by nature (SRE, DevOps, platform, database and storage reliability, anyone whose sprint work coexists with a steady stream of alerts and partner-team asks), a weekly responder rotation breaks silos only when the operational rules force the responder to actually do the work, not route it. Five rules carry the load: a fixed resort order (runbooks → docs → LLM → SME), SMEs as advisors rather than handoff targets, tiered routing for alerts and asks so the responder isn&amp;rsquo;t drowning, improvement tickets that shrink the rotation&amp;rsquo;s load over time, and tech implementation as the only valid mechanism for easing the role.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;A team has a responder rotation. The responder&amp;rsquo;s name is in the channel topic every Monday. Six months in, the database alert that pages at 2am still goes to Sarah because she wrote the schema in 2024 and nobody else has dug into it. The Kafka partition imbalance at 3pm still goes to Marcus. The Redis eviction issue still goes to whoever has been on the team longest. The responder forwarded all three to the original owners within two minutes of the page. The rotation existed. The silos held. The responder name on the calendar was a routing layer, not a learning one.&lt;/p&gt;
&lt;p&gt;That is the failure mode the rest of this post is about. The rotation is necessary but not sufficient. What separates a working rotation from a name on a calendar is a small set of operational rules that force the responder to actually own the work, not just route it.&lt;/p&gt;
&lt;p&gt;The reflex is to write down a clearer rule: the responder handles everything, no exceptions, never escalate. That fails on first contact with a real production page. The 2am Kafka issue is genuinely faster to resolve if Marcus picks up. The customer is on the line. The deadline is tomorrow. Saying &amp;ldquo;no, the responder must learn&amp;rdquo; costs the company money this week and won&amp;rsquo;t carry next week either, because the next 2am page also has a real-world reason it should go to the specialist. &amp;ldquo;No escape&amp;rdquo; is not the rule. The rule that holds is a different shape.&lt;/p&gt;
&lt;h2 id="the-five-rules"&gt;The five rules
&lt;/h2&gt;&lt;p&gt;Each rule sits below the prompt the team gives itself, in a layer the team has to defend actively. None is novel on its own. The combination is what makes the rotation produce the silo-breaking it claims.&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Resort order: runbooks, then docs, then internal LLM, then SME.&lt;/strong&gt; Before the responder asks a human, they work through the documented self-serve options in order. Runbooks for known incidents, docs for system understanding, an internal LLM or RAG tool for synthesis across both, and only after those run out, the SME. The point is not to ban the SME. It is to make sure the responder has tried the cheaper rungs first, so that when the SME does get pulled in, the conversation starts from &amp;ldquo;I read the runbook section on this and tried X, here is what I&amp;rsquo;m seeing&amp;rdquo; instead of &amp;ldquo;what is this and what do I do.&amp;rdquo;&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;SMEs advise, they don&amp;rsquo;t take handoff.&lt;/strong&gt; When the responder consults the SME, the conversation produces a path forward, not a transfer. The SME explains the issue, suggests the next steps, and goes back to their declared work. The responder owns the ticket through resolution and writes the resolution into the runbook. This is the rule that turns &amp;ldquo;ask Marcus&amp;rdquo; into knowledge transfer instead of work transfer.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Alert and ask routing tiered by urgency and source.&lt;/strong&gt; The responder watches a high-signal channel actively for prod alerts and urgent asks, skims a separate channel for non-prod, and works a Jira queue between fires for non-urgent automated signals (failed nightly backup, config drift, flag mismatch). Asks from other engineers, especially storage-team work like Kafka or Redis, go to a live conversation channel rather than a ticket queue; tickets get filed downstream when the conversation produces work worth tracking, they are not the entry point. The detailed channel structure, severity policy, and alert-tuning discipline are their own subject for a future installment in this series.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Improvement tickets generate themselves.&lt;/strong&gt; Every recurring incident produces a ticket. A noisy alert that fired three times this quarter gets a tuning ticket. A runbook gap the responder hit gets a doc-update ticket. An LLM that gave a confident wrong answer gets a source-update ticket. The rule works because the role concentrates pain on one person for five days: the responder absorbs what is normally scattered across the team, and the person paged at 2am is the same person who will write the runbook on Wednesday. If the same fire happens twice in a quarter and no improvement ticket exists, the rotation is wallpapering toil instead of reducing it. The improvement queue is also the team&amp;rsquo;s most honest signal that the rotation is working: if the queue is shrinking and the runbooks are growing, the responder is producing the cross-training the rotation promised.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Reduce responder load only through tech, never through policy carve-outs.&lt;/strong&gt; As the rotation matures and the team wants to make the responder&amp;rsquo;s week easier, the only valid mechanism is technical implementation: new automation, better runbooks, alert tuning, self-service tools for partner teams who keep asking the same questions. Carving out categories back to the SME, lowering the bar for what the responder is expected to handle, or routing painful asks somewhere off-stage all shift toil rather than removing it. Tech implementation takes the toil out of the system entirely. Every responder week that produces a real engineering ticket reduces what the next rotation has to do. Policy carve-outs do the opposite, quietly.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;The SME who keeps picking up is the failure mode&lt;/strong&gt;
 &lt;div&gt;Rule 2 fails more often than any of the other four. The first time the responder pings the SME, the SME thinks &amp;ldquo;I&amp;rsquo;ll just fix this one.&amp;rdquo; The second time, it is a habit. The fix is on the SME, not the responder: when consulted, the right response is &amp;ldquo;this belongs in the runbook&amp;rdquo; or &amp;ldquo;this should be automated,&amp;rdquo; never &amp;ldquo;let me handle it.&amp;rdquo; Every ask that reaches the SME is evidence of a gap the team should close.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;What the five rules buy for the rest of the team is a clean focus week. The non-responder ICs are not in the partner-team channel triaging asks, not fielding DMs about Kafka topics or Redis schema, not on the other end of the 2am page. They are doing the work they declared on Monday, with the morning declaration and 3pm sync from &lt;a class="link" href="https://explainanalyze.com/p/how-teams-actually-finish-what-they-start-part-ii-a-two-stage-standup/" &gt;Part II&lt;/a&gt; structuring their day, and they are finishing what they started. A morning declaration is a goal that four hours of unscheduled interruption will eventually destroy. The rotation is what protects the declaration long enough for the IC to actually finish it.&lt;/p&gt;
&lt;p&gt;The cost of all five is real. Each requires discipline that is easier to skip than to keep. The SME who always picks up will keep picking up unless the team explicitly stops the handoff pattern. The improvement-ticket discipline only works if there is a half-hour each rotation set aside to file them, and rotations under heavy load lose that half-hour first. The tiered routing requires actively maintaining alert filters and channel topology that drift fast. The tech-implementation rule asks the team to file real engineering work after each rotation, which competes with sprint commitments and slips when sprints get tight. None of this is one-time setup. The rules are an active practice the team defends the same way it defends the 3pm sync slot from Part II.&lt;/p&gt;
&lt;h2 id="handoff-at-the-week-boundary"&gt;Handoff at the week boundary
&lt;/h2&gt;&lt;p&gt;The responder&amp;rsquo;s week ends Friday afternoon. Three categories of things sit in the queue.&lt;/p&gt;
&lt;p&gt;Open incidents and paused investigations get explicitly handed off in a written note: what is the state, what is the next action, what was already tried, who has been pulled in. The note is short. Half a page is plenty. Bullet form is fine. The rule is not that everything is exhaustively documented; it is that nothing is silently dropped.&lt;/p&gt;
&lt;p&gt;Closed work gets closed, with whatever runbook update or follow-up ticket the resolution produced. Improvement work the responder identified but didn&amp;rsquo;t get to becomes a backlog ticket, scheduled into someone&amp;rsquo;s planned-work queue rather than left to the next responder to either pick up or ignore.&lt;/p&gt;
&lt;p&gt;The next responder picks up Monday morning with full context on what is actually inbound. No ramp-up day spent figuring out what the previous week was working on. No ticket that quietly got dropped at the rotation boundary because nobody owned it across the weekend.&lt;/p&gt;
&lt;h2 id="what-the-responder-does-not-handle"&gt;What the responder does not handle
&lt;/h2&gt;&lt;p&gt;A working rotation is honest about specialization. Some categories of work genuinely require the specialist, and naming them upfront prevents the format from feeling like a fiction.&lt;/p&gt;
&lt;p&gt;Security incidents. Corner-case data-recovery operations. Load-bearing decisions that require historical context the rotation can&amp;rsquo;t reasonably build (the schema choice from 2022 that has shaped every query since). The responder still owns the ticket and stays in the loop, but the actual work happens with the specialist driving and the responder learning. Over a quarter, the responder may move into the specialist column for some of these. Some they won&amp;rsquo;t, and that&amp;rsquo;s fine.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;Carve-outs need a budget&lt;/strong&gt;
 &lt;div&gt;Three named carve-outs is honest about specialization. Thirty is theater wearing a costume. Keep the list short and explicit so it cannot expand quietly over a quarter into &amp;ldquo;the rotation only handles the easy stuff.&amp;rdquo;&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="when-this-doesnt-apply"&gt;When this doesn&amp;rsquo;t apply
&lt;/h2&gt;&lt;p&gt;The structure earns less than its cost when interrupt volume is too low. If the team gets pinged twice a day and most of those are easily handled, the rotation is a structure without a job. Volume is the test, not team size or specialization. A three-person team with heavy operational load still benefits from concentrating interruptions on one person while the other two ship sprint work; a ten-person team with light load doesn&amp;rsquo;t. A team of four MySQL DBAs benefits more from rotation, not less, because everyone can handle everything; uniform specialization makes the rotation easier, not harder. The format earns its cost when interrupt volume is high enough that focused weeks are otherwise impossible.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;A working responder rotation has visible evidence: improvement tickets are filed every rotation, the rotation&amp;rsquo;s interrupt volume is trending down quarter over quarter, the runbook count and quality is going up, and at handoff Friday the next responder receives a written note rather than a tribal-knowledge briefing. None of that is rocket science. All of it requires the team to defend the rules every week against the easier path of letting the SME pick up.&lt;/p&gt;
&lt;p&gt;The teams that abandon the rotation usually didn&amp;rsquo;t abandon it because the rotation was wrong. They kept the calendar entry and dropped the rules. The SMEs kept picking up, the improvement tickets stopped getting filed, and the rotation became a name in a channel topic that nobody was treating as load-bearing.&lt;/p&gt;</description></item><item><title>Designing Partitioning You Don't Have to Babysit</title><link>https://explainanalyze.com/p/designing-partitioning-you-dont-have-to-babysit/</link><pubDate>Fri, 06 Mar 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/designing-partitioning-you-dont-have-to-babysit/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Designing Partitioning You Don't Have to Babysit" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;Partition by the primary key, not by &lt;code&gt;created_at&lt;/code&gt;, and let a background service manage boundaries based on observed growth. Queries keep using the keys they already have, partition pruning works automatically, and the partition column never leaks into application code. The same &amp;ldquo;service watches and adjusts&amp;rdquo; pattern applies to hash and list partitioning with different operations.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The orders dashboard started loading slowly the week after the partitioning deploy, and the team&amp;rsquo;s first instinct is to blame the new index strategy. The actual culprit shows up in &lt;code&gt;EXPLAIN&lt;/code&gt;: thirty-six lines of &lt;code&gt;Partitions: orders_p2025_01, orders_p2025_02, ...&lt;/code&gt; on a query that&amp;rsquo;s just &lt;code&gt;SELECT * FROM orders WHERE id = 12345&lt;/code&gt;. The plan reads every partition because the WHERE clause doesn&amp;rsquo;t include &lt;code&gt;created_at&lt;/code&gt;, and &lt;code&gt;created_at&lt;/code&gt; is the partition key. The lookup that used to be one index probe is now thirty-six.&lt;/p&gt;
&lt;p&gt;The proposed fix is the one that always gets proposed: add &lt;code&gt;created_at &amp;gt;= '2024-11-01'&lt;/code&gt; to the dashboard query. It works. The plan drops to one partition. Then the audit page does the same thing, then the admin tool, then the migration script. Three months later there&amp;rsquo;s an internal lint rule that flags any &lt;code&gt;SELECT FROM orders&lt;/code&gt; without a date filter, and code reviews include &amp;ldquo;did you add the partition filter?&amp;rdquo; as a standard check. The partition key has stopped being a storage decision and become a contract every query has to honor. Forgetting still produces no error. Just slowness.&lt;/p&gt;
&lt;h2 id="the-partition-key-problem"&gt;The partition key problem
&lt;/h2&gt;&lt;p&gt;Both PostgreSQL and MySQL require the partition key to be part of any primary key or unique constraint on the table. That rule exists for correctness: if the primary key didn&amp;rsquo;t include the partition key, the database couldn&amp;rsquo;t enforce uniqueness without scanning every partition.&lt;/p&gt;
&lt;p&gt;The consequence is that if you want to partition by &lt;code&gt;created_at&lt;/code&gt;, you can&amp;rsquo;t just have &lt;code&gt;PRIMARY KEY (id)&lt;/code&gt; anymore. You need &lt;code&gt;PRIMARY KEY (id, created_at)&lt;/code&gt;. The date column is now part of the primary key whether your application needed it to be or not.&lt;/p&gt;
&lt;p&gt;The more subtle cost is that &lt;code&gt;id&lt;/code&gt; is no longer unique in the eyes of the database. Uniqueness is enforced on the tuple &lt;code&gt;(id, created_at)&lt;/code&gt;: the database will cheerfully accept two rows with the same &lt;code&gt;id&lt;/code&gt; as long as they have different timestamps. The application probably still treats &lt;code&gt;id&lt;/code&gt; as unique, but nothing in the schema guarantees it. And you can&amp;rsquo;t recover the guarantee with a separate &lt;code&gt;UNIQUE (id)&lt;/code&gt; constraint: both MySQL and PostgreSQL require every unique constraint on a partitioned table to include the partition key columns. The uniqueness property has effectively been traded away.&lt;/p&gt;
&lt;p&gt;This isn&amp;rsquo;t purely cosmetic; it changes the query plans the optimizer is willing to generate:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;With &lt;code&gt;PRIMARY KEY (id)&lt;/code&gt;, &lt;code&gt;WHERE id = 1&lt;/code&gt; is a constant-time lookup. MySQL&amp;rsquo;s EXPLAIN shows this as the &lt;code&gt;const&lt;/code&gt; access type; the optimizer knows exactly one row matches and the executor stops after finding it. Joins on &lt;code&gt;id&lt;/code&gt; are &lt;code&gt;eq_ref&lt;/code&gt;, the fastest join access type.&lt;/li&gt;
&lt;li&gt;With &lt;code&gt;PRIMARY KEY (id, created_at)&lt;/code&gt;, the same query becomes a &lt;code&gt;ref&lt;/code&gt; lookup: a prefix scan on the leftmost index column that could, as far as the database is concerned, return multiple rows. Joins that used to be &lt;code&gt;eq_ref&lt;/code&gt; become &lt;code&gt;ref&lt;/code&gt;. Cardinality estimates fall back to index statistics instead of the guaranteed &amp;ldquo;one row&amp;rdquo; assumption, which can push the optimizer toward worse plans further up the query tree.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;To get the old &lt;code&gt;const&lt;/code&gt; plan back, every lookup has to spell out the full primary key:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Was a const lookup, now a ref lookup (one of potentially many rows)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Back to const, but only if the caller knows the created_at
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2026-04-01 12:34:56&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;That&amp;rsquo;s the same leakage as partition pruning, from a different angle: the partition key has forced its way into queries that had nothing to do with dates, first to get pruning and now to get single-row access.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;span class="lnt"&gt;20
&lt;/span&gt;&lt;span class="lnt"&gt;21
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Before partitioning
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total_cents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DATETIME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- After partitioning by month
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total_cents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DATETIME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- created_at forced into the PK
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;RANGE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TO_DAYS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p202601&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LESS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;THAN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TO_DAYS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2026-02-01&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p202602&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LESS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;THAN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;TO_DAYS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2026-03-01&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;At this point everything still works. The table accepts inserts, queries return correct results, and the partition boundaries exist. The problem shows up the first time someone runs a query that doesn&amp;rsquo;t include &lt;code&gt;created_at&lt;/code&gt; in the WHERE clause.&lt;/p&gt;
&lt;h2 id="partition-pruning-only-works-if-you-ask-for-it"&gt;Partition pruning only works if you ask for it
&lt;/h2&gt;&lt;p&gt;Partition pruning is the optimization that makes partitioning worth doing. When a query&amp;rsquo;s WHERE clause restricts the partition key, the database can skip partitions that can&amp;rsquo;t possibly match. A query for last week&amp;rsquo;s orders only reads the one or two partitions that contain last week&amp;rsquo;s data.&lt;/p&gt;
&lt;p&gt;That optimization depends on the partition key appearing in the WHERE clause. A query that filters on anything else doesn&amp;rsquo;t get pruned; it scans every partition.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- This query scans every partition. There are 36 of them.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12345&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- This one prunes to a single partition
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12345&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2026-03-01&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2026-04-01&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The first query is the kind of lookup that happens constantly: fetch an order by its primary key. On a non-partitioned table, it&amp;rsquo;s a single index seek. On a partitioned table where the pruning key isn&amp;rsquo;t in the WHERE clause, it&amp;rsquo;s a separate index probe against every partition: 36 index lookups instead of one. Still fast in absolute terms, but much worse than the non-partitioned version, which is the opposite of why partitioning was introduced.&lt;/p&gt;
&lt;p&gt;The &amp;ldquo;fix&amp;rdquo; teams usually land on is to add the partition key to every query that touches the table. That&amp;rsquo;s a leaky abstraction. A storage decision is now a contract with every caller: new code has to remember the partition filter, old code has to be audited, the ORM has to be configured around it.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;No error, just slowness&lt;/strong&gt;
 &lt;div&gt;A query that should prune but doesn&amp;rsquo;t still returns correct results. The plan just scans every partition. No exception, no warning, no flag in the application logs, only an &lt;code&gt;EXPLAIN&lt;/code&gt; that nobody reads until a dashboard times out. Most teams discover the failure by reviewing slow-query logs after a partition deploy, not from anything the database surfaces during query execution.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="static-partition-boundaries-dont-age-well"&gt;Static partition boundaries don&amp;rsquo;t age well
&lt;/h2&gt;&lt;p&gt;The other thing that tends to go wrong is hardcoding partition boundaries at table creation time. The initial layout reflects whatever the team&amp;rsquo;s growth projection looked like at that moment. Six months later the traffic pattern has changed, some partitions are 10x larger than others, and the &lt;code&gt;p_future&lt;/code&gt; catch-all partition is holding half the table.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Defined at creation: looks reasonable
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p2026_q1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LESS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;THAN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100000000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p2026_q2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LESS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;THAN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200000000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;...&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Six months later: growth accelerated, p_future is now the entire active workload
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p_future&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LESS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;THAN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;MAXVALUE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 800M rows and growing
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Manually splitting and rebalancing partitions is operational work nobody wants to own. It requires scheduling maintenance windows, running &lt;code&gt;ALTER TABLE ... REORGANIZE PARTITION&lt;/code&gt; against tables that might be hundreds of gigabytes, coordinating with application teams, and not making a mistake. It tends not to happen until there&amp;rsquo;s a performance incident, and at that point the fix is expensive.&lt;/p&gt;
&lt;h2 id="the-shape-of-the-better-approach"&gt;The shape of the better approach
&lt;/h2&gt;&lt;p&gt;The primary key already exists. For tables using &lt;code&gt;BIGINT AUTO_INCREMENT&lt;/code&gt;, it&amp;rsquo;s monotonically increasing: newer rows have larger IDs. That&amp;rsquo;s the property range partitioning needs. The primary key is the partition key.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total_cents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;DATETIME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;RANGE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p0001&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LESS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;THAN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;100000000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p0002&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LESS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;THAN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;200000000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p0003&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LESS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;THAN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;300000000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p_future&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LESS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;THAN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;MAXVALUE&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Every query that filters by &lt;code&gt;id&lt;/code&gt; (which is most of them) gets partition pruning for free, with no changes to application code. Range queries by ID prune across a small number of partitions. Point lookups prune to exactly one. The primary key is already in every WHERE clause that matters, because it&amp;rsquo;s the primary key.&lt;/p&gt;
&lt;p&gt;The trade-off is that partition boundaries aren&amp;rsquo;t directly defined by time anymore, which looks like it breaks time-based retention. In practice this is less of a trade-off than it looks; the point of partitioning often isn&amp;rsquo;t retention but keeping index sizes manageable, making maintenance operations cheap, and bounding the blast radius of a bad query. When retention is a goal, boundaries can still be chosen to align with time. They just get picked at DDL time by the partitioner service, rather than baked into the schema. See &lt;a class="link" href="#time-aligned-boundaries-without-a-date-in-the-key" &gt;Time-aligned boundaries without a date in the key&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="automating-range-partition-management"&gt;Automating range partition management
&lt;/h2&gt;&lt;p&gt;Everything up to this point assumes range partitioning: partitions defined by continuous boundaries on an ordered value (an ID range, a date range). The operational work is mechanical: watch the active partition fill up, split the &lt;code&gt;MAXVALUE&lt;/code&gt; catch-all into a new bounded partition before that happens, and drop partitions that have fallen past the retention threshold. A small service running on a schedule is enough to keep the layout healthy. The hard part isn&amp;rsquo;t the logic, it&amp;rsquo;s doing it safely: running DDL against a large table without locking out writes, handling partial failures, and recovering cleanly if the service crashes mid-operation.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Split the catch-all partition into a new bounded partition + new catch-all
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- This is the operation the service runs periodically
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;REORGANIZE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p_future&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p0037&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LESS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;THAN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3700000000&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p_future&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LESS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;THAN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;MAXVALUE&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;REORGANIZE PARTITION&lt;/code&gt; on an empty catch-all partition is fast; there&amp;rsquo;s nothing to move. If you split the catch-all before any rows land above the split point, the operation is metadata-only. The service&amp;rsquo;s job is to stay ahead of the write workload: split the catch-all when it&amp;rsquo;s still small or empty, not when it&amp;rsquo;s already holding hundreds of millions of rows.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;What makes the service tricky in production&lt;/strong&gt;
 &lt;div&gt;The logic is a couple of DDL statements. The hard parts are everything around them: not locking out writes during the &lt;code&gt;REORGANIZE&lt;/code&gt;, surviving a service crash mid-DDL (idempotency on retry), handling concurrent migration tooling that&amp;rsquo;s also taking &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; on the table, and having a clear runbook for &amp;ldquo;the service has stalled and the catch-all is now 200M rows, what do we do.&amp;rdquo; Production-grade partitioners usually spend more code on the operations bracket than on the DDL itself.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;There&amp;rsquo;s no single right target; it depends on what&amp;rsquo;s driving the partitioning in the first place. If the goal is keeping the OLTP working set small via retention, the boundary spacing is a business decision: how long does the data need to stay queryable in the hot store, one year, seven years, somewhere in between. If the goal is performance, sizing each partition so its indexes fit comfortably in memory is a reasonable rule of thumb, provided there&amp;rsquo;s no significant key skew concentrating reads or writes on a single partition. The service can be configured against either target and adjust boundary spacing based on observed growth.&lt;/p&gt;
&lt;h3 id="time-aligned-boundaries-without-a-date-in-the-key"&gt;Time-aligned boundaries without a date in the key
&lt;/h3&gt;&lt;p&gt;Partitioning by &lt;code&gt;id&lt;/code&gt; doesn&amp;rsquo;t mean giving up time-based boundaries; it just means choosing them after the fact. The service can run a single query against the live table to find the ID boundary for any point in time:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Where was the ID pointer at the start of March?
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;MAX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2026-03-01&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- -&amp;gt; 3700842139
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;That value becomes the upper bound for the next bounded partition. The catch-all stays above it, and future partitions get cut at time-aligned ID boundaries:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Partition is still defined by ID range, but chosen to align with a month boundary
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;REORGANIZE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p_future&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p2026_03&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LESS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;THAN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;3700842140&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p_future&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LESS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;THAN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;MAXVALUE&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The resulting partition &lt;code&gt;p2026_03&lt;/code&gt; contains roughly all orders from March 2026, but &lt;code&gt;created_at&lt;/code&gt; never appears in the primary key, never needs to be in any WHERE clause to get pruning, and never leaks into application code. The date column is used once, at boundary-creation time, by the service running the DDL. Queries continue to filter by &lt;code&gt;id&lt;/code&gt; and get pruning for free.&lt;/p&gt;
&lt;p&gt;Retention works the same way. To drop data older than twelve months, the service runs &lt;code&gt;SELECT MAX(id) FROM orders WHERE created_at &amp;lt; NOW() - INTERVAL 12 MONTH&lt;/code&gt;, identifies every partition with an upper bound below that ID, and drops them. The &lt;code&gt;MAXVALUE&lt;/code&gt; catch-all is what makes this pattern work; there&amp;rsquo;s always a place for new rows to land while the service is deciding where to cut next.&lt;/p&gt;
&lt;h3 id="what-the-service-looks-like"&gt;What the service looks like
&lt;/h3&gt;&lt;p&gt;The service itself is small. It runs on a schedule (hourly for high-throughput tables, daily for slower-moving ones) and on each tick it does a handful of things:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Inventory.&lt;/strong&gt; Read the current partition layout from the catalog: partition names, upper bounds, and approximate row counts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Sizing check.&lt;/strong&gt; Look at the active partition (the bounded one just below the catch-all). If it&amp;rsquo;s filled past a configured threshold of the target size, it&amp;rsquo;s time to cut the next boundary.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Boundary selection.&lt;/strong&gt; Pick where to cut. For time-aligned partitions, query the live table for the ID that was current at the next month boundary; that ID becomes the upper bound of the new partition.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Split.&lt;/strong&gt; Reorganize the catch-all into a new bounded partition plus a fresh catch-all above it. As long as the catch-all is still empty when the split runs, this is metadata-only.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Retention pruning.&lt;/strong&gt; Translate the retention window (e.g. twelve months) into an ID via the same &lt;code&gt;created_at&lt;/code&gt;-to-&lt;code&gt;id&lt;/code&gt; lookup, then drop any partition whose upper bound sits below that ID.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Concurrency guard.&lt;/strong&gt; A single advisory lock or leader election so two instances don&amp;rsquo;t run DDL against the same table simultaneously.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Metrics and alerting.&lt;/strong&gt; Per-partition size and row count, time-since-last-tick, and a clear alert if the active partition starts filling faster than the service is splitting ahead of it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Run it as a cron job, a Kubernetes CronJob, or a tiny always-on worker; the operational footprint is intentionally small. The bulk of the production-readiness work goes into the concurrency guard, naming conventions, and handling of split failures from lock contention or concurrent DDL. None of that changes what the service does on each tick: a few catalog reads and one DDL statement.&lt;/p&gt;
&lt;h2 id="existing-tools-and-how-this-pattern-differs"&gt;Existing tools and how this pattern differs
&lt;/h2&gt;&lt;p&gt;The automation itself isn&amp;rsquo;t new; several tools already manage partition boundaries on a schedule. What differs across approaches is which column the partitioning is done on, and how much of the schema contract that choice locks in.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a class="link" href="https://github.com/pgpartman/pg_partman" target="_blank" rel="noopener"
 &gt;pg_partman&lt;/a&gt;&lt;/strong&gt; is the widely used partition manager in the PostgreSQL ecosystem. It pre-creates future partitions on a schedule, drops old ones against a retention window, and can migrate non-partitioned tables into partitioned ones in place. Its defaults (and most tutorials written on top of it) assume time-based range partitioning on a &lt;code&gt;timestamp&lt;/code&gt; column. That&amp;rsquo;s the pattern earlier sections argue against: the date column ends up in the primary key and leaks into every query that expects pruning.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a class="link" href="https://www.timescale.com/" target="_blank" rel="noopener"
 &gt;TimescaleDB&lt;/a&gt;&lt;/strong&gt; goes further. Its &amp;ldquo;hypertables&amp;rdquo; are automatically time-partitioned PostgreSQL tables, with chunk creation, retention, and compression all managed by the extension. It&amp;rsquo;s the right tool for workloads where every query is genuinely time-scoped: observability, IoT telemetry, append-only event streams. It&amp;rsquo;s a worse fit for OLTP tables where some queries are time-scoped and others aren&amp;rsquo;t, because the time column is mandatory and every non-time query pays the same partition-key-in-the-WHERE-clause tax as manual time partitioning.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a class="link" href="https://vitess.io/" target="_blank" rel="noopener"
 &gt;Vitess&lt;/a&gt;&lt;/strong&gt; includes partition management as part of its broader MySQL sharding solution. Its partitioning conventions are flexible, but most production uses land on the same time-based defaults.&lt;/p&gt;
&lt;p&gt;The common thread across all three: the automation layer assumes the partition key is picked in advance (usually a time column) and manages boundaries on top of that assumption.&lt;/p&gt;
&lt;p&gt;The approach in this post keeps the automation pattern (small service, pre-split the catch-all, drop behind retention) but changes the key choice. The partition key is the primary key, and time alignment is derived at DDL time via the &lt;code&gt;SELECT MAX(id) WHERE created_at &amp;lt; X&lt;/code&gt; lookup. The schema-level contract stays &lt;code&gt;PRIMARY KEY (id)&lt;/code&gt;; time-based retention still works, computed once per boundary instead of baked into every query.&lt;/p&gt;
&lt;p&gt;The trade-off is owning the service. pg_partman is a well-tested extension; a DIY partitioner is real operational surface area: advisory locks, failure recovery, metrics, alerts. The useful question is whether &lt;code&gt;created_at&lt;/code&gt; is already a natural filter in every query that matters. If every query is time-scoped by design (observability, telemetry, audit logs, anything time-series) pg_partman or TimescaleDB against a time column is the right answer. The partition key isn&amp;rsquo;t leaking because it was already there.&lt;/p&gt;
&lt;p&gt;If the workload is mixed (some queries filter by date, most don&amp;rsquo;t) then adding &lt;code&gt;created_at&lt;/code&gt; to the PK forces a choice: retrofit every non-time query with a date filter, or eat full partition scans on point lookups. Either option is the overhead, whether it&amp;rsquo;s already visible as slow queries or just spreading through the codebase as &lt;code&gt;AND created_at &amp;gt;= ?&lt;/code&gt; clauses added &amp;ldquo;for partitioning reasons&amp;rdquo; on queries that have nothing to do with dates. At that point, owning a small DDL service is cheaper than propagating a partition key through every caller forever.&lt;/p&gt;
&lt;h2 id="automating-hash-and-list-partitioning"&gt;Automating hash and list partitioning
&lt;/h2&gt;&lt;p&gt;The same &amp;ldquo;service watches and adjusts&amp;rdquo; idea transfers to other partitioning strategies, but the operations change.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hash partitioning&lt;/strong&gt; distributes rows across a fixed number of partitions via a hash function. With good cardinality, every partition grows at roughly the same rate; there&amp;rsquo;s nothing to split based on growth. What there is to monitor is skew: a low-cardinality column or a hot key produces one partition that grows faster than the others, which is the failure mode partitioning was supposed to prevent.&lt;/p&gt;
&lt;p&gt;Automation here isn&amp;rsquo;t about adjusting boundaries. Changing the hash partition count rebuilds the entire table, which isn&amp;rsquo;t something a background service should trigger. The useful work is detection: track per-partition size and growth, surface skew early, run &lt;code&gt;OPTIMIZE&lt;/code&gt; or &lt;code&gt;VACUUM FULL&lt;/code&gt; on partitions as they bloat. The service flags problems; a human decides whether to reshape the table. The consequence is that hash partition count is a decision to get right the first time. Over-provisioning (64 partitions when 16 would do today) is cheap insurance against a later full rebuild.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;List partitioning&lt;/strong&gt; maps enumerated values to specific partitions: one partition per region, per significant tenant, etc., with the long tail in a &lt;code&gt;DEFAULT&lt;/code&gt; catch-all. The automation problem is partition promotion: when a value in the catch-all grows large enough to deserve its own partition.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Starting state: named partitions for known-large tenants, DEFAULT for the rest
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;events&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;JSON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tenant_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;LIST&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;tenant_id&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p_tenant_42&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p_tenant_73&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;73&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p_default&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- The service notices tenant_id = 108 is now 15% of p_default and growing quickly.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- It promotes that tenant into its own partition.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;REORGANIZE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p_default&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p_tenant_108&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;108&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p_default&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DEFAULT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Promotion is more expensive than splitting an empty range catch-all (the rows for that tenant have to physically move out of &lt;code&gt;DEFAULT&lt;/code&gt; into the new partition) but it can be batched and scheduled during low-traffic windows. Dormant values can be merged back into &lt;code&gt;DEFAULT&lt;/code&gt; in the reverse direction to keep the partition count bounded.&lt;/p&gt;
&lt;h2 id="what-actually-belongs-in-the-partition-key"&gt;What actually belongs in the partition key
&lt;/h2&gt;&lt;p&gt;The question worth asking before adopting any partitioning scheme: what column is already in every query that matters? In most OLTP systems the answer is the primary key. It sits in every lookup, every join, every foreign-key fetch, so partitioning by it gets pruning for free. The other answers are real but narrower. &lt;code&gt;tenant_id&lt;/code&gt; works in a multi-tenant system if every query is tenant-scoped; a date column works if the workload is time-series and every query already filters by date. When those conditions don&amp;rsquo;t hold, the partition key leaks into application code the first time someone writes a query without it.&lt;/p&gt;
&lt;p&gt;The failure mode is partitioning by a column that isn&amp;rsquo;t already in every query, then retrofitting the query layer to add it. That&amp;rsquo;s the path that ends with &lt;code&gt;AND created_at &amp;gt;= ?&lt;/code&gt; stapled onto queries that have nothing to do with dates, just to avoid a 36-partition scan.&lt;/p&gt;</description></item><item><title>How Teams Actually Finish What They Start, Part II: A Two-Stage Standup</title><link>https://explainanalyze.com/p/how-teams-actually-finish-what-they-start-part-ii-a-two-stage-standup/</link><pubDate>Sat, 28 Feb 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/how-teams-actually-finish-what-they-start-part-ii-a-two-stage-standup/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post How Teams Actually Finish What They Start, Part II: A Two-Stage Standup" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;Teams ship more when each IC declares one focused goal in writing every morning and the team holds a brief sync at roughly 3pm to surface what isn&amp;rsquo;t going to plan and offer help on it. The morning declaration creates focus and commitment. The mid-day sync catches problems while there is still a work day left to fix them, with help that&amp;rsquo;s actionable now rather than tomorrow.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;It is 3pm on a Tuesday. The team meets for fifteen minutes. The first engineer reads back this morning&amp;rsquo;s declaration: green CI on the orders-export flake by 1pm. What actually happened: CI is green, but the snapshot endpoint is slow enough that the test takes eight minutes, which probably isn&amp;rsquo;t tolerable. Two minutes of conversation. Someone else hit a similar slow-endpoint problem last quarter and links the PR. A third engineer suggests a smaller fix that gets the test under three minutes. The first engineer goes back to their desk with a concrete next step and three hours to finish.&lt;/p&gt;
&lt;p&gt;A 9am standup could not have produced that conversation. At 9am the engineer didn&amp;rsquo;t know the snapshot endpoint was slow; they were committing to green CI. By the time they hit the slow-endpoint problem at 12:30, the morning meeting was four hours behind them, and the teammate with the fix had context-switched to something else. By 5pm, the conversation that could have unblocked the engineer is happening with no work day left to act on it. 3pm is the sweet spot, and the morning declaration is what the 3pm session is anchored to.&lt;/p&gt;
&lt;p&gt;The reflex is to fix the morning meeting instead. Run it tighter. Ask better questions. Replace it with Geekbot or an async post in Slack. Each produces a marginal improvement without changing what the format can do, because the morning is the wrong moment for solving problems. At 9am, today&amp;rsquo;s blockers haven&amp;rsquo;t surfaced yet. The conversations that could resolve them are happening eight hours before the conversations are useful.&lt;/p&gt;
&lt;h2 id="two-artifacts-one-cadence"&gt;Two artifacts, one cadence
&lt;/h2&gt;&lt;p&gt;The morning declaration goes into the team channel before the IC starts work, typically by 10:00 local time. The post answers three questions: what I&amp;rsquo;m working on today, what outcome I&amp;rsquo;m aiming for, what could block me. Two to four sentences. A single focused goal, not a list of three. A statement of intent that the IC, the lead, and the rest of the team can all read.&lt;/p&gt;
&lt;p&gt;A real example:&lt;/p&gt;

 &lt;blockquote&gt;
 &lt;p&gt;Working on the orders-export flake (#4127). Aim: green CI by 1pm. Blocker if it turns out to be the upstream snapshot endpoint, in which case I&amp;rsquo;ll switch to the dashboard cleanup ticket.&lt;/p&gt;

 &lt;/blockquote&gt;
&lt;p&gt;Compare to &amp;ldquo;today I&amp;rsquo;ll keep working on bug #4127, no blockers.&amp;rdquo; The first names a target and a failure path. The second is filler. The act of writing the first one forces the IC to commit to something concrete and to audit whether it fits in a day. The second is something an IC can recite half-asleep.&lt;/p&gt;
&lt;p&gt;The mid-day sync meets around 3pm, fifteen to thirty minutes. Each IC&amp;rsquo;s contribution starts with status: what I declared this morning, what actually happened so far, where I am right now. That part is unavoidable and useful. The rest of the team needs the read. The differentiator is the second beat: &lt;em&gt;what isn&amp;rsquo;t going to plan, what could help.&lt;/em&gt; Each contribution is anchored to the morning post, which means the team works through real, named issues in real time once the status piece is on the table.&lt;/p&gt;
&lt;p&gt;3pm is the load-bearing detail. A meeting at 5pm reports problems that already cost the team a day. A meeting at 11am hasn&amp;rsquo;t seen most of today&amp;rsquo;s blockers yet. 3pm is when today&amp;rsquo;s reality is mostly visible and today&amp;rsquo;s work day still has runway. A blocker named at 3pm is one a teammate can suggest a fix for at 3:05 and the IC can act on at 3:10. The same blocker named at 5pm is tomorrow&amp;rsquo;s problem.&lt;/p&gt;
&lt;p&gt;The two artifacts are paired. The morning declaration is what the 3pm sync is anchored to. Without the morning, the 3pm meeting is a synchronous standup with all of standup&amp;rsquo;s problems. Without the 3pm sync, the morning is a pile of writing the team never discusses, and the productivity benefit evaporates the same way classic standup&amp;rsquo;s information does.&lt;/p&gt;
&lt;h2 id="why-this-raises-output"&gt;Why this raises output
&lt;/h2&gt;&lt;p&gt;Several mechanisms compound.&lt;/p&gt;
&lt;p&gt;One focused goal per IC, declared in writing, beats a list of three. Teams that try to ship four things a day per IC finish fewer than teams that pick one, and Sophie Leroy&amp;rsquo;s 2009 paper on &amp;ldquo;attention residue&amp;rdquo; (&lt;em&gt;Organizational Behavior and Human Decision Processes&lt;/em&gt;, vol. 109) names the mechanism: cognitive load from an unfinished task A persists into task B, even when nothing in the environment is reminding the worker of A. Declaring one thing in the morning forces the IC to name the priority before the noise starts. The declaration is also a forcing function for honest scoping: an engineer writing &amp;ldquo;today I&amp;rsquo;ll finish the export refactor and start the migration&amp;rdquo; notices, in the act of writing, that two days of work won&amp;rsquo;t fit in one. Speaking has no such friction. A standup at 9am hears &amp;ldquo;I&amp;rsquo;ll work on the export and start the migration&amp;rdquo; and nobody, including the engineer, has audited the claim.&lt;/p&gt;
&lt;p&gt;Writing is binding, and the binding holds because the writing sticks. The commitment-and-consistency principle in social psychology (Cialdini&amp;rsquo;s &lt;em&gt;Influence&lt;/em&gt;, 1984, building on Deutsch and Gerard&amp;rsquo;s 1955 work on normative social influence) is the canonical statement: written commitments produce more consistent follow-through than spoken or merely-considered ones. A goal stated in writing, in a public channel, with a name attached, is a stronger commitment than the same goal mumbled aloud at 9:02. By 10:00, most of what was said at 9:00 is gone, not only from the listeners but from the speaker, who at 4pm cannot reliably recall what they committed to. The morning post is still readable at 4pm. Anyone who joined late, anyone in a different time zone, anyone in a partner team that needs to know what your team is touching today, can read the channel.&lt;/p&gt;
&lt;p&gt;The live 3pm sync produces saves the rest of the format cannot. Someone&amp;rsquo;s blocker is someone else&amp;rsquo;s two-minute fix; the live, bounded conversation surfaces the fix while the IC still has runway to apply it. Async help in Slack threads doesn&amp;rsquo;t carry the same load: nobody is required to read at any particular time, and a fix offered at 4:45pm with the IC heads-down is functionally a fix offered tomorrow. The sync works because everyone is present, the conversation is short, and the help is actionable now.&lt;/p&gt;
&lt;p&gt;The sync also builds a different kind of team feeling than ad-hoc help does. Going through real issues together in a session everyone planned to attend produces hints, half-remembered prior fixes, and &amp;ldquo;oh, I had this last week&amp;rdquo; moments that don&amp;rsquo;t carry the social cost of random interruption. A teammate pinged at 2:34pm pays a context-switch cost the asker doesn&amp;rsquo;t see; Gloria Mark&amp;rsquo;s research at UC Irvine on interrupted work has consistently measured refocus times around twenty to twenty-five minutes after a single interruption. That cost disappears when the conversation is the format. Over a quarter, ICs hear each other think out loud and the team starts to know each other as collaborators rather than as Slack avatars.&lt;/p&gt;
&lt;p&gt;Achievement compounds morale. Declaring something and getting it done is its own reinforcement. Teresa Amabile and Steven Kramer&amp;rsquo;s analysis of nearly 12,000 daily diary entries from 238 knowledge workers (&lt;em&gt;The Progress Principle&lt;/em&gt;, Harvard Business Review Press, 2011) found that perceiving daily progress on meaningful work was the single biggest driver of inner work life and motivation, larger than recognition or compensation. A team where ICs make daily declarations and meet most of them ends each week with a visible record of completed commitments. That isn&amp;rsquo;t a soft benefit. Over a quarter, it&amp;rsquo;s the difference between a team that perceives itself as producing and one that doesn&amp;rsquo;t, and the perception drives the next quarter&amp;rsquo;s output. Teams that don&amp;rsquo;t see their own progress slow down regardless of the underlying work; teams that do speed up.&lt;/p&gt;
&lt;p&gt;Inclusivity falls out of the format. Written-first formats produce more balanced contribution than live round-robins. Quiet team members get equal weight when their declaration is in the channel, and the 3pm session anchors back to what they wrote rather than rewarding whoever talks first. Over a year, this is the difference between knowing what your introverts are doing and not.&lt;/p&gt;
&lt;h2 id="where-this-fits-among-existing-patterns"&gt;Where this fits among existing patterns
&lt;/h2&gt;&lt;p&gt;Most components are not new.&lt;/p&gt;
&lt;p&gt;Async written standups are widely practiced. &lt;a class="link" href="https://geekbot.com/" target="_blank" rel="noopener"
 &gt;Geekbot&lt;/a&gt;, &lt;a class="link" href="https://standuply.com/" target="_blank" rel="noopener"
 &gt;Standuply&lt;/a&gt;, &lt;a class="link" href="https://www.range.co/" target="_blank" rel="noopener"
 &gt;Range&lt;/a&gt;, and &lt;a class="link" href="https://www.polly.ai/" target="_blank" rel="noopener"
 &gt;Polly&lt;/a&gt; are tools built specifically for the morning-write half. &lt;a class="link" href="https://basecamp.com/features/checkins" target="_blank" rel="noopener"
 &gt;Basecamp&amp;rsquo;s Check-ins&lt;/a&gt; does the same thing. GitLab&amp;rsquo;s handbook documents an async-first variant publicly, as do Doist (Twist), Automattic, and parts of Basecamp&amp;rsquo;s own engineering. Anyone who has worked at a remote-first org in the last five years has probably written one of these.&lt;/p&gt;
&lt;p&gt;What is less common is the deliberate pairing with a same-day live sync, and the timing of that sync at 3pm rather than at end-of-day. Most teams that move to async written standups skip the live half entirely. Most teams that keep a live standup don&amp;rsquo;t bother with the written morning. End-of-day retros exist in some agile shops but tend to surface problems too late to act on them today. The 3pm window is the productivity unlock, and it isn&amp;rsquo;t formalized in any of the named tools.&lt;/p&gt;
&lt;p&gt;The other less-common piece is using the written trail as a coaching primitive. Some tools surface trends, but most teams treat written standups as status updates rather than as longitudinal evidence about how an IC scopes their work. The same artifact that makes the morning bind makes it readable as a coaching signal across days and weeks.&lt;/p&gt;
&lt;p&gt;The article isn&amp;rsquo;t claiming async standups are new. The combination (one-goal morning declaration + 3pm team sync + multi-day longitudinal use) is the angle worth picking apart.&lt;/p&gt;
&lt;h2 id="how-team-leads-use-the-trail"&gt;How team leads use the trail
&lt;/h2&gt;&lt;p&gt;A week of declarations, read together, is a different artifact from any single one. A lead reading Monday-through-Friday for one IC sees the rhythm of declarations, what&amp;rsquo;s being achieved, what&amp;rsquo;s drifting, where the IC chooses to spend focus.&lt;/p&gt;
&lt;p&gt;Three patterns:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Weekly review.&lt;/strong&gt; Skim the week&amp;rsquo;s declarations on Friday or Monday. Look for ICs whose intent and outcome diverged repeatedly. Look for blockers that recur. Look for scope that grew without being renegotiated.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;1:1 prep.&lt;/strong&gt; Walk into the 1:1 with the IC&amp;rsquo;s last two weeks of declarations open. The conversation has a concrete anchor instead of &amp;ldquo;how&amp;rsquo;s everything going.&amp;rdquo;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Drift detection.&lt;/strong&gt; When an IC&amp;rsquo;s declarations stop matching outcomes, when blockers recur, when commitments shrink without explanation, the trail surfaces it before the next sprint review does.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The discipline cuts both ways. A lead who reads declarations as ammunition turns the artifact into a control mechanism, and the team adapts by writing declarations that are safely vague.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;The trail is not evidence&lt;/strong&gt;
 &lt;div&gt;The fastest way to kill this format is to use it as a productivity-tracking tool. A lead who cites past declarations as evidence against an IC, who treats missed commitments as a record to hold, who reads the trail to judge instead of to help, breaks it within a quarter. The team adapts: declarations get written defensively, blockers go unmentioned because admitting one is now on the record, and the real coordination work quietly moves into DMs and 1:1 channels where nothing is being filed. What&amp;rsquo;s left in the public channel is theater. The longitudinal coaching value disappears and the productivity gain goes with it. The format depends on honest declarations and the team feeling that the trail exists to help, not to judge. It amplifies whatever culture is already there. It does not fix a broken one.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="failure-modes"&gt;Failure modes
&lt;/h2&gt;&lt;p&gt;The structure has its own anti-patterns.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Declarations become theater.&lt;/strong&gt; &amp;ldquo;My goal: fix bugs&amp;rdquo; produces nothing useful at 3pm because there&amp;rsquo;s nothing concrete to talk through. The fix is to push the format toward concrete outcomes (a ticket number, a measurable end-state) and to model good declarations from the lead&amp;rsquo;s own posts.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The 3pm sync collapses to status alone.&lt;/strong&gt; Some status is built into the format and that&amp;rsquo;s fine. The failure mode is when status is the whole meeting. When the lead asks &amp;ldquo;what&amp;rsquo;s your update&amp;rdquo; and nobody jumps in with a fix, a question, or &amp;ldquo;I had this last week,&amp;rdquo; the meeting has reverted to a synchronous standup with extra writing overhead. The discipline is the second beat: surfacing what isn&amp;rsquo;t working and inviting help on it before the next person reports.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Over-commitment in writing.&lt;/strong&gt; Engineers declare more than fits in a day because written commitments feel public. Two weeks of &amp;ldquo;declared three things, finished one&amp;rdquo; is a coaching signal, not a discipline issue. The format is asking for honest scoping; the lead&amp;rsquo;s job is to model it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Declarations nobody reads.&lt;/strong&gt; If the morning post goes into a channel nobody scrolls, the persistence benefit evaporates and the 3pm session has no anchor. The fix isn&amp;rsquo;t more pings. It&amp;rsquo;s making the channel a routine read for the lead, the partner teams, and the IC&amp;rsquo;s peers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The 3pm slot gets eroded by other meetings.&lt;/strong&gt; Mid-afternoon is prime calendar real estate, and over a quarter the slot can be eaten by partner-team syncs, reviews, and one-offs. Defend it. Moving the sync to 4:30 to accommodate another meeting kills the productivity property the 3pm timing was buying.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="when-classic-standup-is-still-the-right-tool"&gt;When classic standup is still the right tool
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t a one-size-fits-all replacement.&lt;/p&gt;
&lt;p&gt;Tight single-time-zone teams get less from the async morning. A team in one office, all in the same five-hour window, can run a synchronous standup at 9:00 and the persistence benefit is small because everyone is already in the same room. The 3pm sync still pays back; the morning declaration is the part that becomes optional.&lt;/p&gt;
&lt;p&gt;Very small teams (three or four people) face overhead high relative to size. A daily fifteen-minute conversation accomplishes most of what the two-stage format does, and the multi-day coaching trail matters less when the lead can ask any of three people directly.&lt;/p&gt;
&lt;p&gt;Pair-programming and mob-programming workflows complicate the morning declaration. The artifact becomes the pair&amp;rsquo;s intent, not the individual&amp;rsquo;s. The 3pm sync still works; the longitudinal trail is less useful because the artifact you&amp;rsquo;re reading is the pair, not the IC.&lt;/p&gt;
&lt;p&gt;Crisis modes are the clearest exception. Incident response, deploy days, anything where the team is already on the same shared context for hours. Use a war-room channel and resume the regular cadence after.&lt;/p&gt;
&lt;h2 id="trade-offs"&gt;Trade-offs
&lt;/h2&gt;&lt;p&gt;This isn&amp;rsquo;t free.&lt;/p&gt;
&lt;p&gt;ICs do more writing every day. Five sentences before 10am may not sound like much, but compounded across a year and a team of ten it&amp;rsquo;s real overhead. The format is paying for that with output, morale, and a coaching trail. If none of those matter in the team&amp;rsquo;s context, the cost isn&amp;rsquo;t worth it.&lt;/p&gt;
&lt;p&gt;The 3pm slot is expensive calendar real estate. Carving fifteen to thirty minutes out of the most productive part of the work day is a real tax, paid back in problems solved while still solvable. A team that can&amp;rsquo;t defend the slot will see the productivity benefit decay over a quarter as the meeting slides later or gets cancelled.&lt;/p&gt;
&lt;p&gt;The format depends on psychological safety. A team where missed commitments become punishment material, where blockers are punished as weakness, will produce performative declarations regardless of the format. The structure amplifies whatever the team&amp;rsquo;s actual culture is.&lt;/p&gt;
&lt;p&gt;The longitudinal trail can be misused. Already named in the failure modes; the most expensive failure because it kills the productivity benefit fastest. The same artifact that makes the format work as coaching makes it dangerous as compliance theater.&lt;/p&gt;
&lt;p&gt;The tooling fit is partial. Geekbot, Standuply, Range, Polly, and Basecamp Check-ins each support parts of the structure. None map perfectly to &amp;ldquo;morning declaration + 3pm live sync + longitudinal trail.&amp;rdquo; Most teams running this end up with a Slack channel for the morning, a recurring 3pm calendar event, and the lead&amp;rsquo;s own notes for the trail. The tooling is a wrapper around the discipline, not a substitute for it.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;The test for whether this format fits a team is concrete. Does the lead read the morning declarations, and does the 3pm meeting actually run as a working sync? If both are true, the productivity gain is real. If either drifts (the channel goes unread, or the meeting becomes a status round) the format collapses to a more expensive version of the standup it was supposed to replace.&lt;/p&gt;
&lt;p&gt;Teams that try this and abandon it usually abandoned it because they kept the format and dropped the discipline. The format is cheap. The discipline is the part that ships.&lt;/p&gt;</description></item><item><title>Testing Your Database, Part 2: What to Test, and How</title><link>https://explainanalyze.com/p/testing-your-database-part-2-what-to-test-and-how/</link><pubDate>Tue, 17 Feb 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/testing-your-database-part-2-what-to-test-and-how/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Testing Your Database, Part 2: What to Test, and How" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;What most teams call &amp;ldquo;database tests&amp;rdquo; are application tests with a database underneath. They cover whether the code reads and writes correctly, not whether the database does what its catalog claims. Real database testing covers five distinct categories, each requiring a different tool, and each invisible to the others.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The CI suite has 600 tests. Every one is green. Judged on what they actually exercise: 480 are unit tests using a stub database for fixtures, 80 are application integration tests using a real Postgres container with seeded fixtures, 20 are migration tests that run &lt;code&gt;db:migrate&lt;/code&gt; against the empty test database, 20 are end-to-end tests that hit the API and observe response shape. Coverage looks comprehensive. None of these tests catches: an &lt;code&gt;ALTER TABLE&lt;/code&gt; that locks the table for 40 minutes against production volume, a &lt;code&gt;CHECK&lt;/code&gt; constraint that&amp;rsquo;s syntactically valid and semantically wrong, an &lt;code&gt;ON CONFLICT (email)&lt;/code&gt; that depends on a UNIQUE constraint nobody declared, a JOIN that multiplies rows through a missing bridge UNIQUE, a query that returns the right shape with the wrong number, a generated column whose definition drifts from its dependencies after a migration. The suite isn&amp;rsquo;t bad. The failures live in categories the suite doesn&amp;rsquo;t cover.&lt;/p&gt;
&lt;p&gt;The obvious response is &amp;ldquo;use Testcontainers.&amp;rdquo; Testcontainers is a real tool, addresses a real gap (the test container is the production engine, not SQLite-as-Postgres), and most teams should adopt it. It still only addresses one of the categories below. A team that adopts Testcontainers and stops there has moved from &amp;ldquo;no database tests&amp;rdquo; to &amp;ldquo;application tests with a real database engine&amp;rdquo;; better, and still missing the four other categories. The same applies to every other &amp;ldquo;the one thing we should do&amp;rdquo; answer: each tool below addresses a class of failure the others can&amp;rsquo;t see, and an AI-introduced bug can land in any of them.&lt;/p&gt;
&lt;h2 id="five-categories-of-database-test"&gt;Five categories of database test
&lt;/h2&gt;&lt;h3 id="1-lint-ddl-safety-before-merge"&gt;1. Lint DDL safety before merge
&lt;/h3&gt;&lt;p&gt;The category most teams don&amp;rsquo;t have at all. The test runs against the migration source itself (not the database) and checks for patterns known to lock or rewrite tables in production: &lt;code&gt;ADD COLUMN ... NOT NULL&lt;/code&gt; without a default-then-backfill split (Postgres pre-11) or a non-constant default (Postgres 11+ stores it metadata-only), &lt;code&gt;ALTER COLUMN TYPE&lt;/code&gt; that triggers a table rewrite, &lt;code&gt;ADD FOREIGN KEY&lt;/code&gt; without &lt;code&gt;NOT VALID&lt;/code&gt;, &lt;code&gt;DROP COLUMN&lt;/code&gt; on a table other services still write, indexes created without &lt;code&gt;CONCURRENTLY&lt;/code&gt; (Postgres) or without &lt;code&gt;ALGORITHM=INPLACE, LOCK=NONE&lt;/code&gt; (MySQL).&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tool.&lt;/strong&gt; &lt;a class="link" href="https://github.com/sbdchd/squawk" target="_blank" rel="noopener"
 &gt;Squawk&lt;/a&gt; for Postgres; lints SQL migrations for known-bad patterns. Configurable rule set, fast failure, runs as a CI step or pre-commit hook. &lt;a class="link" href="https://atlasgo.io/" target="_blank" rel="noopener"
 &gt;Atlas&lt;/a&gt; for cross-engine coverage (Postgres, MySQL, ClickHouse); its 2025 analyzer set covers destructive changes, data-dependent modifications like &lt;code&gt;ADD COLUMN NOT NULL&lt;/code&gt; without a default, nested transactions, and SQL-injection-prone migration code, with hooks designed to gate AI-authored migrations specifically. For MySQL, &lt;code&gt;pt-online-schema-change&lt;/code&gt; and &lt;code&gt;gh-ost&lt;/code&gt; defaults plus a custom lint script that flags raw &lt;code&gt;ALTER&lt;/code&gt; on tables over a configured row threshold.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What it catches.&lt;/strong&gt; The locking-migration class from &lt;a class="link" href="https://explainanalyze.com/p/testing-your-database-part-1-why-ai-made-it-mandatory/" &gt;Part 1&lt;/a&gt;: &lt;code&gt;ALTER TABLE users ADD COLUMN tier TINYINT NOT NULL DEFAULT 0&lt;/code&gt; against a 50M-row table. Squawk&amp;rsquo;s &lt;code&gt;adding-not-nullable-field&lt;/code&gt;, &lt;code&gt;disallowed-unique-constraint&lt;/code&gt;, and &lt;code&gt;require-concurrent-index-creation&lt;/code&gt; rules surface this pattern before the migration is applied anywhere.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What it misses.&lt;/strong&gt; Anything that requires running the migration to detect. It&amp;rsquo;s a syntactic check. A migration that&amp;rsquo;s safe by lint but breaks an invariant the team relied on still passes.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="2-assert-what-the-catalog-says"&gt;2. Assert what the catalog says
&lt;/h3&gt;&lt;p&gt;The catalog is full of declarations that are easy to write incorrectly and hard to read back. Did the FK actually cascade, or did it default to &lt;code&gt;NO ACTION&lt;/code&gt;? Does the partial index cover the predicate the query actually uses? Does the CHECK reject the values it claims to? After a migration applies, are the constraints, indexes, and triggers the team intended actually present, with the names, columns, and behavior they intended? Tests in this category run after migrations apply and assert against the resulting schema and the resulting behavior, not the application&amp;rsquo;s view of it.&lt;/p&gt;
&lt;p&gt;A note specifically on database-resident business logic. The companion post &lt;a class="link" href="https://explainanalyze.com/p/where-business-logic-lives-database-vs.-application/" &gt;Where Business Logic Lives&lt;/a&gt; argues to keep most of it in the application layer, where review density and tooling are higher. Triggers, stored procedures, functions, RLS policies, generated-column expressions, and multi-statement &lt;code&gt;CHECK&lt;/code&gt; constraints encoding state-machine rules are running code that doesn&amp;rsquo;t show up on a normal PR diff and doesn&amp;rsquo;t execute in local development the way application code does. If business logic is in the database (by accident, by legacy, or by deliberate choice) every one of those needs unit-test coverage the same way an application function would. For stored procedures and functions: assert every branch, every &lt;code&gt;EXCEPTION&lt;/code&gt; block, every side effect on the rows the procedure touches, every return value the caller depends on. For triggers: assert each firing condition (&lt;code&gt;BEFORE&lt;/code&gt; / &lt;code&gt;AFTER&lt;/code&gt; × &lt;code&gt;INSERT&lt;/code&gt; / &lt;code&gt;UPDATE&lt;/code&gt; / &lt;code&gt;DELETE&lt;/code&gt;), every &lt;code&gt;WHEN&lt;/code&gt; filter, the actual state change the action performs, and any interaction with other triggers on the same table. The same pgTAP / tSQLt harness covers all of it; the discipline is treating database code as code, not as configuration. A trigger that&amp;rsquo;s never been asserted against isn&amp;rsquo;t a guarantee. It&amp;rsquo;s a hope.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tool.&lt;/strong&gt; &lt;a class="link" href="https://pgtap.org/" target="_blank" rel="noopener"
 &gt;pgTAP&lt;/a&gt; for Postgres, &lt;a class="link" href="https://tsqlt.org/" target="_blank" rel="noopener"
 &gt;tSQLt&lt;/a&gt; for SQL Server, &lt;a class="link" href="https://www.utplsql.org/" target="_blank" rel="noopener"
 &gt;utPLSQL&lt;/a&gt; for Oracle. Schema-level assertions: &lt;code&gt;has_column('users', 'tier')&lt;/code&gt;, &lt;code&gt;col_type_is('users', 'tier', 'integer')&lt;/code&gt;, &lt;code&gt;has_index('users', 'users_email_idx')&lt;/code&gt;, &lt;code&gt;fk_ok('orders', 'user_id', 'users', 'id')&lt;/code&gt;. Behavior-level assertions: insert an illegal row and assert the CHECK rejects, insert a parent and child and delete the parent and assert the cascade fired, attempt a forbidden state transition and assert it&amp;rsquo;s rejected.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What it catches.&lt;/strong&gt; The CHECK that lists valid values but doesn&amp;rsquo;t constrain transitions. The FK declared without &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; despite the team thinking it was. The partial index whose &lt;code&gt;WHERE&lt;/code&gt; clause has drifted from the queries that use it. The UNIQUE constraint the upsert depends on but nobody declared.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What it misses.&lt;/strong&gt; Anything outside the catalog: query result correctness, lock duration, performance regressions, runtime data invariants.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="3-assert-query-results-not-just-shapes"&gt;3. Assert query results, not just shapes
&lt;/h3&gt;&lt;p&gt;The hardest category and the most under-covered. The query is syntactically valid, the result set is the right shape, the EXPLAIN is clean, and the number is wrong by 30%. The test that catches this asserts the result against a known dataset: given a fixture with 1,000 users where 200 are soft-deleted, the active-users query returns 800; given five orders with stacked discounts, the revenue query returns the discounted total, not the row-multiplied total.&lt;/p&gt;
&lt;p&gt;Example-based fixtures aren&amp;rsquo;t enough on their own. The AI-generated bugs in this category live in inputs the test author didn&amp;rsquo;t think to write. Take a paginated query the model emits as &lt;code&gt;ORDER BY created_at LIMIT 100 OFFSET ?&lt;/code&gt;. An example test inserts ten orders, paginates through them, gets all ten back, passes. The bug (that &lt;code&gt;created_at&lt;/code&gt; isn&amp;rsquo;t unique, so rows with identical timestamps swap positions between pages and rows get skipped or duplicated) never surfaces against ten hand-written rows. A property-based test that asserts &amp;ldquo;every row appears exactly once across all pages&amp;rdquo; finds the timestamp collision in seconds and the fix is a deterministic tie-breaker (&lt;code&gt;ORDER BY created_at, id&lt;/code&gt;). The same pattern applies to round-trip (insert and read back unchanged), conservation (sum of children equals parent total), and idempotency (running twice equals running once).&lt;/p&gt;
&lt;p&gt;Property and fixture tests assert that the result is correct against a known input. They don&amp;rsquo;t assert the query means what the human asked. The second tier addresses that gap: dual-track evaluation, where the query runs programmatically (count, aggregate, expected rows) and a separate LLM judge scores semantic alignment against the original natural-language intent, the schema, and the result set. Thomson Reuters&amp;rsquo; internal SQL agent shipped with 73% silent-failure rate on time-based analyses (predicates landed on the parent date column but not the joined ones); adding a &amp;ldquo;consistent time constraint across joined tables&amp;rdquo; validator plus dual-track judge eval drove it below 10%.&lt;/p&gt;
&lt;p&gt;The 10% is lab-clean and best-case. TR measured it against curated analytical queries with known ground truth on an instrumented internal data lake. The gap from there to a typical legacy schema (undeclared FKs, polysemic &lt;code&gt;TINYINT&lt;/code&gt; status columns, tribal-knowledge soft-deletes, four-format &lt;code&gt;VARCHAR&lt;/code&gt; dates, JSON-as-schema, the realities in &lt;a class="link" href="https://explainanalyze.com/p/what-ai-gets-wrong-about-your-database/" &gt;What AI Gets Wrong About Your Database&lt;/a&gt;) multiplies that rate, because the judge reads the same impoverished catalog the generator does. 10% is unshippable on its own: a daily financial query at 10% silent-failure lays down corrupted data every week, errors layer into next month&amp;rsquo;s inputs, and by the time a customer flags the discrepancy six months later the WAL retention is exhausted and the backups have rolled past. That&amp;rsquo;s a continuity event, not a bug. Treat LLM-judge eval as a floor-raiser for what reaches human review, never the release gate. The gate is property tests, fixture-based result assertions, and human review against representative data; the judge sits underneath all three.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tool.&lt;/strong&gt; &lt;a class="link" href="https://docs.getdbt.com/docs/build/data-tests" target="_blank" rel="noopener"
 &gt;dbt tests&lt;/a&gt; for analytical/transformation SQL: built-in &lt;code&gt;unique&lt;/code&gt;, &lt;code&gt;not_null&lt;/code&gt;, &lt;code&gt;accepted_values&lt;/code&gt;, &lt;code&gt;relationships&lt;/code&gt;, plus custom SQL tests. &lt;a class="link" href="https://github.com/sodadata/soda-core" target="_blank" rel="noopener"
 &gt;Soda Core&lt;/a&gt; for production data quality assertions. For application SQL, custom integration tests that load a representative fixture, run the query, and assert the count and one or two known aggregates. &lt;a class="link" href="https://hypothesis.readthedocs.io/" target="_blank" rel="noopener"
 &gt;Hypothesis&lt;/a&gt; (Python), &lt;a class="link" href="https://github.com/dubzzz/fast-check" target="_blank" rel="noopener"
 &gt;fast-check&lt;/a&gt; (TypeScript), or &lt;a class="link" href="https://propertesting.com/" target="_blank" rel="noopener"
 &gt;PropEr&lt;/a&gt; (Erlang) for property-based generators that exercise the input distribution the fixture doesn&amp;rsquo;t. &lt;a class="link" href="https://github.com/datafold/data-diff" target="_blank" rel="noopener"
 &gt;data-diff&lt;/a&gt; for regression: run the query against the same dataset before and after a change, fail if the result diff is larger than expected. For semantic verification of AI-generated SQL: LLM-judge templates from Arize, Evidently, Langfuse, or Monte Carlo, scoped to the referenced tables only; schema bloat poisons the judge the same way it poisons the generator. Differential testing (running the agent&amp;rsquo;s query and a hand-written reference against the same dataset and diffing) is the natural extension. No productized tool exists for it yet, and any team adopting AI-authored SQL at scale should be ready to roll their own harness.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What it catches.&lt;/strong&gt; The opening incident from &lt;a class="link" href="https://explainanalyze.com/p/testing-your-database-part-1-why-ai-made-it-mandatory/" &gt;Part 1&lt;/a&gt;: the soft-delete-naive &lt;code&gt;LEFT JOIN&lt;/code&gt; that over-reported revenue by 7%. The JOIN cardinality blowup through a bridge table without composite &lt;code&gt;UNIQUE&lt;/code&gt;. The polysemic-TINYINT predicate landing on the wrong meaning. The pagination that drops rows on timestamp ties. The temporal-misalignment failure from the Thomson Reuters case: predicates landing on the parent date column but not the joined ones. The aggregate that ran in 80ms and was off by $1.4M. Anything where the failure shape is &amp;ldquo;result is plausible but wrong.&amp;rdquo;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What it misses.&lt;/strong&gt; Anything that only manifests at production scale, under concurrency, or against data shapes the fixture and the property generators didn&amp;rsquo;t include. The LLM-judge tier specifically misses any failure mode invisible from the result set: a query that returns the right number for the wrong reason still passes.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="4-run-the-migration-and-budget-what-it-costs"&gt;4. Run the migration and budget what it costs
&lt;/h3&gt;&lt;p&gt;The category that catches the locking migration from Part 1. The test applies the migration to a database the size of production (or a representative fraction), measures lock duration with &lt;code&gt;pg_locks&lt;/code&gt; or &lt;code&gt;information_schema.innodb_trx&lt;/code&gt;, runs concurrent reads and writes against the table to surface metadata-lock contention, and asserts against a budget. Same idea for queries: run EXPLAIN against a representative dataset, assert the planner uses the index the team meant, assert the cost is below a budget, assert the query plan didn&amp;rsquo;t change unexpectedly between the previous and current revisions.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Tool.&lt;/strong&gt; &lt;a class="link" href="https://testcontainers.com/" target="_blank" rel="noopener"
 &gt;Testcontainers&lt;/a&gt; for spinning up a real database engine inside the test runner, seeded with an anonymized prod-shaped snapshot (&lt;a class="link" href="https://postgresql-anonymizer.readthedocs.io/" target="_blank" rel="noopener"
 &gt;pg_anonymizer&lt;/a&gt; or equivalent for the snapshot pipeline). A test harness that applies the migration with a stopwatch and a &lt;code&gt;pg_locks&lt;/code&gt; watcher running in a parallel session. EXPLAIN budgets via per-query assertions in CI; production query-plan regressions via &lt;code&gt;pg_stat_statements&lt;/code&gt; snapshots.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What it catches.&lt;/strong&gt; The 40-minute migration. The query that&amp;rsquo;s fast on 10K rows and slow on 10M. The index the planner ignores. The migration that succeeds in isolation and deadlocks against concurrent writers.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What it misses.&lt;/strong&gt; Catalog-level invariants the migration doesn&amp;rsquo;t change but the test should still verify; data invariants that drift over time independent of any migration.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="5-catch-data-drift-after-the-schema-is-correct"&gt;5. Catch data drift after the schema is correct
&lt;/h3&gt;&lt;p&gt;The category most useful for catching production drift, not pre-deployment bugs. Once a day, in CI or as a scheduled job, run a set of assertions against actual production data: every order has a user, every user with an active subscription has a payment method, the soft-delete column is consistent across related tables, the JSON keys in the column match the documented shape, the count of &lt;code&gt;users.deleted_at IS NOT NULL&lt;/code&gt; matches the count of soft-delete audit records. These assertions don&amp;rsquo;t run against fixtures; they run against the real data and surface inconsistencies the schema can&amp;rsquo;t enforce - the integrity rules that live in application code, not in the catalog.&lt;/p&gt;
&lt;p&gt;Soda Core, &lt;a class="link" href="https://greatexpectations.io/" target="_blank" rel="noopener"
 &gt;Great Expectations&lt;/a&gt;, and custom SQL assertions wrapped in a test runner that fails loudly and pages on a missed assertion are the standard toolchain. &lt;a class="link" href="https://schemathesis.readthedocs.io/" target="_blank" rel="noopener"
 &gt;Schemathesis&lt;/a&gt; is the cousin that handles property-based testing of API contracts hitting the database, useful for catching drift introduced through the API rather than through backfills. What this layer catches is what the schema cannot: soft-delete inconsistencies between related tables, orphaned rows the FK should have caught but didn&amp;rsquo;t (because the FK was declared after the orphans existed and was created &lt;code&gt;NOT VALID&lt;/code&gt;), JSON shape drift, business-logic invariants that live in application code and got bypassed by a backfill or a one-off script. What it can&amp;rsquo;t catch is pre-deployment bugs. By the time a data invariant fires, the bad data is already there; this category is the safety net under the others, not a replacement for them.&lt;/p&gt;
&lt;h2 id="the-minimum-useful-subset"&gt;The minimum useful subset
&lt;/h2&gt;&lt;p&gt;Five categories is more than most teams will adopt at once. The order to add them, ranked by leverage per hour invested:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;&lt;strong&gt;DDL safety lint&lt;/strong&gt; (Squawk or equivalent). One config file, zero runtime cost, catches the highest-impact failure mode: schema migrations that lock production. Adopt this week.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Schema invariants&lt;/strong&gt; (pgTAP for Postgres, tSQLt for SQL Server). One test file per migration, asserting the migration produced the schema the team intended and that constraints behave the way the team thinks. Catches the constraints that look right and aren&amp;rsquo;t.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lock and performance budgets&lt;/strong&gt; (Testcontainers + prod-shaped snapshot). The largest setup cost - the snapshot pipeline has to be built and maintained - and the largest payoff. Catches the failures that only manifest at production scale.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Query result regressions&lt;/strong&gt; (dbt tests or custom integration tests). High value for analytical workloads, lower for transactional. Pick the queries that drive business decisions and assert their results against fixtures; expand from there.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data invariants&lt;/strong&gt; (Soda or scheduled SQL). Useful once the pre-deployment categories are in place. Without them, you&amp;rsquo;re chasing drift the earlier categories should have prevented.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;A team that has none of these and adopts the first three closes most of the AI-introduced surface from &lt;a class="link" href="https://explainanalyze.com/p/testing-your-database-part-1-why-ai-made-it-mandatory/" &gt;Part 1&lt;/a&gt;. A team that has all five has built the verification layer that, pre-AI, lived in the heads of senior engineers - now written down, runnable on demand, and cheap to re-run on every change.&lt;/p&gt;
&lt;h2 id="the-recovery-layer"&gt;The recovery layer
&lt;/h2&gt;&lt;p&gt;The five categories test the database on the way in. They don&amp;rsquo;t test the path back when something AI-introduced makes it past every category and corrupts data anyway. Backups that have never been restored aren&amp;rsquo;t backups; they&amp;rsquo;re unverified hopes. Replication and failover that have never been broken on purpose aren&amp;rsquo;t HA; they&amp;rsquo;re configuration that hasn&amp;rsquo;t been disproved.&lt;/p&gt;
&lt;p&gt;The drills are mechanical: pull a random backup from the last seven days, restore to a fresh ephemeral instance, run a checksum query against a known table, destroy the instance. Daily. Alert on failure. Same idea for point-in-time recovery: pick a timestamp from yesterday, restore to it, verify. Same for failover: kill the primary on a schedule in staging, confirm promotion, restore. Each drill costs an hour or two to automate and catches the class of failure where backups silently stopped working three months ago and nobody noticed because nobody needed them yet.&lt;/p&gt;
&lt;p&gt;The framing shifts with agents in the loop. An agent that holds write permissions on production can cause damage at machine speed; recovery has to work at machine speed too. A four-hour restore that requires three engineers isn&amp;rsquo;t a recovery procedure. It&amp;rsquo;s a postmortem.&lt;/p&gt;
&lt;h2 id="the-performance-environment"&gt;The performance environment
&lt;/h2&gt;&lt;p&gt;Category 4 covers per-PR migration safety and per-query plan budgets, good for CI feedback loops, where a Testcontainers engine spins up, runs one operation, asserts a budget, and tears down. That&amp;rsquo;s the wrong shape for the failure modes that only emerge under sustained load: throughput collapse under realistic concurrency, p95/p99 latency creeping past SLO under traffic mix, replication lag under write pressure that staging never produces, connection pool saturation when a new query plan blows the average query duration, buffer cache thrash when an added index pushes hot data out of &lt;code&gt;shared_buffers&lt;/code&gt;, and the slow degradation that only shows up after the cache has warmed and the workload has run for hours.&lt;/p&gt;
&lt;p&gt;The environment those tests need is a shadow of production: same data volume, same replication topology, ideally same hardware shape, populated from an anonymized snapshot refreshed on a regular cadence. Traffic comes from replay rather than synthesis. Capture statement traces with &lt;code&gt;pg_stat_statements&lt;/code&gt; or query logs over a representative window, and replay them with &lt;a class="link" href="https://github.com/laurenz/pgreplay" target="_blank" rel="noopener"
 &gt;pg_replay&lt;/a&gt;, Percona Playback, or a custom harness against the shadow at production rate. Synthetic load generators (&lt;a class="link" href="https://k6.io/" target="_blank" rel="noopener"
 &gt;k6&lt;/a&gt;, &lt;a class="link" href="https://jmeter.apache.org/" target="_blank" rel="noopener"
 &gt;JMeter&lt;/a&gt;) work for application-level scenarios but miss the long-tail query distribution that production carries, which is exactly where AI-introduced regressions hide.&lt;/p&gt;
&lt;p&gt;The payoff is the failure class CI cannot reach: a migration passes every Testcontainers check, deploys to production, and degrades p99 latency 40% three hours later because the new index shifted the planner&amp;rsquo;s choice for a different query the CI run never executed. That regression is invisible in a five-second container and obvious in a four-hour replay.&lt;/p&gt;
&lt;p&gt;The cloud collapses most of the setup cost. &lt;a class="link" href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/Aurora.Managing.Clone.html" target="_blank" rel="noopener"
 &gt;Aurora&amp;rsquo;s fast database cloning&lt;/a&gt; is copy-on-write against the source. Ready in minutes regardless of database size, you only pay for the diff, and you tear it down when done. &lt;a class="link" href="https://neon.com/docs/introduction/branching" target="_blank" rel="noopener"
 &gt;Neon&amp;rsquo;s branches&lt;/a&gt; do the same for managed Postgres outside Aurora. Google Cloud SQL clones and Azure SQL database copy are slower but in the same family. Plain RDS without Aurora is slower still (snapshot + restore) but cheaper than building the pipeline yourself. The &amp;ldquo;we&amp;rsquo;d need to maintain a parallel copy of production&amp;rdquo; objection that used to kill this kind of testing infrastructure is a one-API-call problem in 2026 for anyone on managed cloud Postgres or MySQL: clone prod on demand, run the migration and the replay against the clone, throw it away. The blocker shifted from infrastructure to discipline. Are AI-authored migrations actually running this gate before merge, or is the clone capability sitting unused?&lt;/p&gt;
&lt;p&gt;A read-only replica in production is the degenerate version of this. It&amp;rsquo;s the cheapest shadow you&amp;rsquo;ll ever build, and the answer to &amp;ldquo;is the new query going to scan a billion rows&amp;rdquo; is to run it on the replica before merging. Many teams already have one; far fewer route AI-generated queries through it as a standing gate.&lt;/p&gt;
&lt;h2 id="when-this-doesnt-apply"&gt;When this doesn&amp;rsquo;t apply
&lt;/h2&gt;&lt;p&gt;The minimum useful subset assumes a production-facing system with multiple writers, frequent migrations, and AI in the loop. Cases where less is enough:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;A read-only analytical workload with no migrations.&lt;/strong&gt; Categories 1 and 2 don&amp;rsquo;t apply. Category 3 (result regressions) and category 5 (data invariants) carry the load.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A throwaway service with one writer and no enduring data.&lt;/strong&gt; None of this is necessary; the cost of a wrong query is recoverable.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A team with zero AI in its data layer.&lt;/strong&gt; The case for the test categories is weaker - the implicit human review is intact. The categories still matter, but they aren&amp;rsquo;t load-bearing in the same way.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;A schema small and stable enough to fit in one head.&lt;/strong&gt; Twenty tables, three engineers, one service writing every row. The reviewer who wrote the migration is the test, the same way they always were. Grow the team or the schema by an order of magnitude and the math flips.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For everything else, the cost of building the test layer is a fraction of the cost of one production incident. AI made the math obvious.&lt;/p&gt;</description></item><item><title>How Teams Actually Finish What They Start, Part I: Designing the Team as a Process</title><link>https://explainanalyze.com/p/how-teams-actually-finish-what-they-start-part-i-designing-the-team-as-a-process/</link><pubDate>Wed, 11 Feb 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/how-teams-actually-finish-what-they-start-part-i-designing-the-team-as-a-process/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post How Teams Actually Finish What They Start, Part I: Designing the Team as a Process" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;A team&amp;rsquo;s output and motivation come from designing the work as a process: each person in a role that uses them well, an owned outcome attached to it, a cadence chosen deliberately. Every team&amp;rsquo;s process is custom; the operating principle is universal. Break the work into chunks the team can consume, run them on a schedule the team can hold, and the team ships like a clock.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Six engineers, three quarters in. Weekly standup, retro every two weeks, sprint planning Monday, sprint review Friday, an architecture review the second Tuesday of the month, OKR tracking, the usual mix of Slack channels. The team ships, but the velocity chart has been flat for two quarters and three engineers are quietly looking. Retros produce theme complaints (too many meetings, scope creep) and no underlying diagnosis. From the outside the team looks well-managed; from inside it feels like rituals running on inertia. Nobody can answer &amp;ldquo;what is this team&amp;rsquo;s process actually for&amp;rdquo; without falling back on the names of meetings.&lt;/p&gt;
&lt;p&gt;The reflex is to cut meetings. Cancel one, the others stay; the calendar gets quieter, the velocity stays flat, and the same three engineers keep looking. The problem isn&amp;rsquo;t meeting volume. The team has no process design: no answer to what each person is at the team to produce, what role they fill, what output flows from them to whom. Cutting rituals doesn&amp;rsquo;t add design. It removes friction from a system that wasn&amp;rsquo;t producing anything in particular.&lt;/p&gt;
&lt;h2 id="process-beats-capacity"&gt;Process beats capacity
&lt;/h2&gt;&lt;p&gt;NYC port had less infrastructure than Boston or Charleston in 1817. By 1830 it was the dominant Atlantic port and stayed that way for the next century. The difference was one decision in 1818: Black Ball Line started running ships on a published monthly schedule from NYC to Liverpool, full hold or not. Boston ran ships when there was cargo. Charleston the same. Traders started routing through NYC because they could plan around the schedule. Volume followed predictability, predictability compounded into market position, and the gap kept widening for a century. The engineering equivalent is the same shape. A team that ships on schedule draws downstream commitments. Partner teams plan around it. Leadership trusts it with bigger scope. Customers stop hedging. A team that ships fast when convenient does not.&lt;/p&gt;
&lt;p&gt;Andy Grove&amp;rsquo;s &lt;em&gt;High Output Management&lt;/em&gt; opens with a breakfast service. Same people, same stove, same eggs. With role design (one cook on the line, one runner, one dishwasher) the kitchen produces roughly twice the output of the same people working as a generalist mob. Grove&amp;rsquo;s frame is that a manager&amp;rsquo;s output is the output of the organization under their influence, and the leverage is in process design rather than in the manager&amp;rsquo;s individual speed at any task. An engineering team is the same shape. A team where everyone does everything looks egalitarian and produces less than a team where each person owns a role that uses their strengths and feeds the next person&amp;rsquo;s input.&lt;/p&gt;
&lt;p&gt;NASA&amp;rsquo;s Apollo mission control ran the same shape under maximum stakes. Each flight controller owned a specific system: FIDO for trajectory, RETRO for the return burn, EECOM for electrical and life support. The Flight Director consolidated. Each role had a clear handoff to the people whose decisions depended on theirs. The same engineers working as generalists across every screen would have caught nothing in time. When Apollo 13&amp;rsquo;s CO2 scrubber failed, the room routed the problem to the right console in seconds. The role design was what made that speed possible.&lt;/p&gt;
&lt;p&gt;These run on the same mechanism: consumable chunks on a held schedule, each station fed by the one before it. The Toyota Production System named the principle decades later. Place each person where the work arrives. Arrange the layout so output flows to the next station without wasted motion. A line designed that way produces more than the same people working as a generalist mob, because the design strips out the waiting, the searching, and the reaching that eat individual capacity. Engineering teams that ship reliably run the same shape, with whatever chunks and whatever schedule the work can sustain.&lt;/p&gt;
&lt;h2 id="roles-as-the-unit-of-design"&gt;Roles as the unit of design
&lt;/h2&gt;&lt;p&gt;Each station on that line is a role. &lt;em&gt;Team Topologies&lt;/em&gt; frames a team as exposing an API: the interface other teams plan against. The same shape applies inside a team. A role is an interface. The rest of the team needs to know what outcome it produces, who is accountable, and the cadence it runs on. Take the code-review queue. Reviewing PRs is the activity, not the role. The engineer on this week&amp;rsquo;s rotation is accountable for the queue. The outcome is PRs reviewed within four hours, with reviewers learning from each other in the process. The cadence is weekly: the rotation rolls every Monday. A team with that interface explicit has a designed role. A team with just the activity has a queue that may or may not produce anything. Roles fit together by design: one role&amp;rsquo;s output is another&amp;rsquo;s input, and engineers cooperate at the boundaries instead of throwing work over a wall. Owning a scope means accountability for what comes out of it, not territory.&lt;/p&gt;
&lt;p&gt;A team&amp;rsquo;s process is the set of roles it runs and the way work passes between them. A team without designed roles still has roles. They emerged accidentally, drifted to whoever was loudest or most senior, and rarely get pruned. Most silos start there: a role belongs to one person not because the team chose them, but because they happened to be the one who picked it up.&lt;/p&gt;
&lt;p&gt;Three rules separate designed roles from drifted ones:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Custom to the team, not a template.&lt;/strong&gt; Spotify Squads, Atlassian Goalies, the latest FAANG framework: every successful process is the team&amp;rsquo;s own. Importing someone else&amp;rsquo;s wholesale gets the form without the function. Templates are inputs to design, not the design itself.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Each role owns an outcome, not a task list.&lt;/strong&gt; A role that lists what to do produces compliance. A role that names the outcome the IC owns produces ownership. The difference shows up over a quarter: owners go deep, propose improvements, push back on bad designs, and stay engaged. Task-fillers don&amp;rsquo;t. The team&amp;rsquo;s reputation across the org follows the depth: whatever the team owns gets serious treatment, and the team gets credit for it.&lt;/p&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;p&gt;&lt;strong&gt;Cadence justifies itself by output.&lt;/strong&gt; A weekly meeting that exists because &amp;ldquo;that is our cadence&amp;rdquo; is a role optimized for nothing in particular. Each recurring role should serve a specific output. An SRE-to-INFRA weekly runs weekly because cross-team dependency surprises happen on a weekly horizon, and the meeting is less evil than the alternative of implementing in silos and surprising each other with operational load. Roles without a justification should be ad-hoc, set up when a purpose appears and dissolved when it goes away.&lt;/p&gt;
&lt;/li&gt;
&lt;/ol&gt;
&lt;h2 id="the-managers-role"&gt;The manager&amp;rsquo;s role
&lt;/h2&gt;&lt;p&gt;The manager designs the interface and picks the people to staff it. Which roles exist, what each one produces, the cadences, the handoffs - those are decisions, not emergent properties. The manager hires into the roles, understands what each engineer does well and badly, and places them where the role&amp;rsquo;s outcome and the engineer&amp;rsquo;s strengths meet. When two roles overlap or interfere, redrawing the boundary is the manager&amp;rsquo;s call.&lt;/p&gt;
&lt;p&gt;The manager also decides last. ICs propose. The manager arbitrates from the only seat in the room that sees all roles at once. A team that runs every decision past its manager has a manager doing the team&amp;rsquo;s work. The reverse failure is a team that never escalates anything, where the manager has quietly stopped owning the scope.&lt;/p&gt;
&lt;p&gt;Priorities are the other lever. Most engineers have a tendency to rewrite things they find ugly.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;The rewrite cycle&lt;/strong&gt;
 &lt;div&gt;If it were up to engineers, no release would ever ship. The legacy gets rewritten, the rewrite ages into legacy, and the rewrite of the rewrite begins.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The manager carries the business context that engineers do not always see: what the company is trying to do this quarter, what each system costs to run, what the customer actually pays for. That context turns &amp;ldquo;rebuild this because it&amp;rsquo;s ugly&amp;rdquo; into &amp;ldquo;fix the slow part first because it doubles conversion.&amp;rdquo; Low-hanging fruit and process optimization beat rebuilding from scratch nearly every time.&lt;/p&gt;
&lt;p&gt;Take a talented IC. Do they ship more inside the process you built than they would alongside the same teammates with no designed roles at all? Grove&amp;rsquo;s frame from the breakfast service makes this the central test: a manager&amp;rsquo;s output is the output of the team under their influence. A process that fits the work makes a strong IC compound: their output flows into a role where someone else is waiting for it, and that person&amp;rsquo;s output flows somewhere in turn. Without designed roles, the same IC works hard and the team still drifts, because the work has nowhere to go between people. That delta, between what the IC ships with the process and what they ship without it, is the manager&amp;rsquo;s actual output.&lt;/p&gt;
&lt;p&gt;The delta has a second face that velocity charts miss. A role with a named outcome and a clear handoff lets the IC running it see who is waiting on their work and what gets built on top of it. That visibility is what makes the team feel like a team, and what makes engineers want to push the work forward instead of clearing tickets. A process that ships work but leaves engineers feeling interchangeable is missing the half of the manager&amp;rsquo;s output that the engineers themselves feel.&lt;/p&gt;
&lt;h2 id="write-the-interface-down"&gt;Write the interface down
&lt;/h2&gt;&lt;p&gt;That kind of visibility does not happen by default. The interface only exists for the rest of the team if it is written down. Every role gets a short entry: the owner, the outcome it produces, the cadence it runs on, the SLA it holds itself to, who pages who when something slips. &lt;em&gt;Team Topologies&lt;/em&gt; publishes a Team API template for the team-to-team version, and the same shape works scoped down to the role level. The doc lives somewhere everyone reads, whether a wiki page, a pinned Slack thread, or the README of a process repo. Without a written record the interface lives in one person&amp;rsquo;s head, and the team is one departure away from rediscovering whose role each thing was. The doc is also where the team negotiates changes: an outcome shifts, a cadence moves, an owner rotates, and the change is visible to everyone affected before it happens. The same doc carries into quarterly reviews. A role&amp;rsquo;s outcome is what its owner committed to producing, and the review can work from a shared reference instead of from whatever incidents are easiest to recall. Engineers know in advance what the bar is.&lt;/p&gt;
&lt;h2 id="parts-ii-through-v-as-worked-examples"&gt;Parts II through V as worked examples
&lt;/h2&gt;&lt;p&gt;This series puts role design to work. &lt;a class="link" href="https://explainanalyze.com/p/how-teams-actually-finish-what-they-start-part-ii-a-two-stage-standup/" &gt;Part II&lt;/a&gt; covers the morning declaration and 3pm sync as a productivity cadence: two roles that turn morning intent into the day&amp;rsquo;s output. &lt;a class="link" href="https://explainanalyze.com/p/how-teams-actually-finish-what-they-start-part-iii-a-working-responder-rotation/" &gt;Part III&lt;/a&gt; covers the responder rotation as the role that protects the others, especially for SRE, DevOps, and platform teams whose work is otherwise pinged into noise. &lt;a class="link" href="https://explainanalyze.com/p/how-teams-actually-finish-what-they-start-part-iv-point-after-the-fact/" &gt;Part IV&lt;/a&gt; covers pointing tickets after they close, as the measurement discipline that tells the team where the design is actually working and where it is not. &lt;a class="link" href="https://explainanalyze.com/p/how-teams-actually-finish-what-they-start-part-v-the-sprint-as-a-working-set/" &gt;Part V&lt;/a&gt; covers what the sprint should actually contain - work in flight plus immediate next pulls, with priority living in labels and the team pulling from the labeled backlog.&lt;/p&gt;
&lt;p&gt;All four are worked examples of the same discipline. Name the role, name the outcome, design the cadence to fit, measure what comes out, and prune the roles that don&amp;rsquo;t justify themselves. None of them is a template to copy. The discipline transfers; the specifics depend on your team.&lt;/p&gt;
&lt;h2 id="when-this-doesnt-apply"&gt;When this doesn&amp;rsquo;t apply
&lt;/h2&gt;&lt;p&gt;Process design overhead exceeds benefit in four cases.&lt;/p&gt;
&lt;p&gt;Genuinely small teams (three or fewer) where the &amp;ldquo;process&amp;rdquo; is mostly synchronous conversation. Designing roles for three people is over-engineering when the three of them can talk in real time about anything that matters.&lt;/p&gt;
&lt;p&gt;Pre-PMF or product-discovery teams, where the activity boundaries themselves are unstable. When the team does not yet know what it should be producing, designing roles around outcomes locks in the wrong shape, and the cost of redesigning weekly outpaces the cost of running ad-hoc. Stay loose until the work has a recognizable rhythm, then design.&lt;/p&gt;
&lt;p&gt;Pure-research environments where output is uncertain by definition. Process design assumes a known output to optimize for; research breaks that assumption. The right structure for research is closer to &amp;ldquo;give people room to wander and meet to share findings.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Crisis modes. Incidents, deploy days, market events. The ad-hoc response is the right move, and process discipline is the wrong tool until the crisis is over. Resume role discipline after.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;A team designed as a process is one whose members can answer &amp;ldquo;what role do I fill, what outcome do I own, what flows in and out.&amp;rdquo; That answer is the difference between rituals running on inertia and rituals serving output. From the outside the rituals look the same; the team&amp;rsquo;s quarter does not.&lt;/p&gt;
&lt;p&gt;The teams that improve over a year tend to be the teams where someone designed the process: pruned the roles that did not justify themselves, gave each surviving role an owner, made the cadence honest. Teams that don&amp;rsquo;t improve usually didn&amp;rsquo;t have that work happening.&lt;/p&gt;</description></item><item><title>Testing Your Database, Part 1: Why AI Made It Mandatory</title><link>https://explainanalyze.com/p/testing-your-database-part-1-why-ai-made-it-mandatory/</link><pubDate>Tue, 10 Feb 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/testing-your-database-part-1-why-ai-made-it-mandatory/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Testing Your Database, Part 1: Why AI Made It Mandatory" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;AI removed the implicit human review that used to catch database bugs. The engineer writing the migration was the test, and that test was never written down or runnable in CI. Once an LLM writes any portion of your data layer, the test suite is the only line between hallucinated SQL and a production incident.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;A senior engineer asks an assistant to write a query returning each user&amp;rsquo;s total order value. The codebase has used soft deletes for three years; &lt;code&gt;deleted_at&lt;/code&gt; columns are on most tables. The model produces:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;amount&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="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LEFT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;deleted_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The query compiles. It runs. It returns numbers. It&amp;rsquo;s wrong: there&amp;rsquo;s no &lt;code&gt;o.deleted_at IS NULL&lt;/code&gt; predicate, so soft-deleted orders inflate every total. The reviewer skims the diff, sees &lt;code&gt;LEFT JOIN&lt;/code&gt;, sees &lt;code&gt;COALESCE&lt;/code&gt;, sees &lt;code&gt;deleted_at IS NULL&lt;/code&gt; somewhere in the &lt;code&gt;WHERE&lt;/code&gt; clause, approves. The bug ships. A finance dashboard over-reports revenue by 7% for two months until a customer flags a discrepancy against their own records.&lt;/p&gt;
&lt;h2 id="the-new-shape-of-database-failure"&gt;The new shape of database failure
&lt;/h2&gt;&lt;p&gt;The bugs an LLM introduces don&amp;rsquo;t look like junior-developer bugs. Junior devs ship SQL that doesn&amp;rsquo;t compile, throws an obvious type error, or returns nothing at all: the kind of failure CI catches by running the query once. The model ships SQL that compiles, runs, and returns something, just not the right something. It pattern-matched on a million open-source codebases and missed the rule that lives only in yours: the soft-delete convention this team adopted three years ago, the multi-tenancy filter every query is supposed to carry, the polysemic &lt;code&gt;TINYINT&lt;/code&gt; whose &lt;code&gt;0&lt;/code&gt; means &amp;ldquo;unknown&amp;rdquo; in one column and &amp;ldquo;free tier&amp;rdquo; in another, the denormalized counter that has to be bumped in the same transaction as the row it counts.&lt;/p&gt;
&lt;p&gt;The obvious response is &amp;ldquo;review AI-generated code more carefully.&amp;rdquo; That doesn&amp;rsquo;t survive contact with how reviews actually happen. &lt;a class="link" href="https://www.anthropic.com/research/AI-assistance-coding-skills" target="_blank" rel="noopener"
 &gt;Engineers using AI assistance score measurably lower on comprehension quizzes about the code they shipped&lt;/a&gt;; they read the output less carefully than the code they would have written themselves, the syntax is right, the change is small, and the reviewer ratifies. Even if that effect didn&amp;rsquo;t exist, the reviewer who can spot the missing &lt;code&gt;o.deleted_at IS NULL&lt;/code&gt; is the same reviewer who would have written the predicate in the first place, which is exactly the value AI was supposed to provide. &amp;ldquo;Review more carefully&amp;rdquo; asks the team to do a check the AI was supposed to obviate.&lt;/p&gt;
&lt;p&gt;It also misses how broad the surface is. The same reasoning shape (plausible SQL that pattern-matches on training data and misses the local rule) produces migrations that lock production at scale, upserts that assume a &lt;code&gt;UNIQUE&lt;/code&gt; that was never declared, indexes the planner won&amp;rsquo;t use, and &lt;code&gt;CHECK&lt;/code&gt; constraints that allow the state transition the team wanted to forbid. The SQL is syntactically valid in every case; the EXPLAIN is clean; the failure only appears against real data, real concurrency, real volume, or the conventions that live in nobody&amp;rsquo;s head but the team&amp;rsquo;s.&lt;/p&gt;
&lt;h2 id="what-the-experienced-engineer-was-doing-that-ci-doesnt"&gt;What the experienced engineer was doing that CI doesn&amp;rsquo;t
&lt;/h2&gt;&lt;p&gt;Before the model wrote that &lt;code&gt;LEFT JOIN&lt;/code&gt;, an engineer who had been on the team for a year would have done several things in their head:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Recognized the soft-delete convention from the column naming pattern and added &lt;code&gt;o.deleted_at IS NULL&lt;/code&gt; to the join, because the team&amp;rsquo;s rule is &amp;ldquo;every query that isn&amp;rsquo;t an audit filters soft-deletes on every joined table.&amp;rdquo;&lt;/li&gt;
&lt;li&gt;Considered whether the report should attribute orders to soft-deleted users at all (probably not for revenue; possibly yes for retention analysis) and asked the requester instead of guessing.&lt;/li&gt;
&lt;li&gt;Recognized &lt;code&gt;o.amount&lt;/code&gt; is nullable for refund rows and decided whether refunds count toward the total or not.&lt;/li&gt;
&lt;li&gt;Checked whether the &lt;code&gt;orders&lt;/code&gt; table has a &lt;code&gt;tenant_id&lt;/code&gt; column the multi-tenancy filter requires, and added the predicate the codebase&amp;rsquo;s row-level security depends on.&lt;/li&gt;
&lt;li&gt;Recalled that an earlier version of this exact report shipped six months ago with a JOIN through &lt;code&gt;order_items&lt;/code&gt;, doubled every total, and required a backfill, and chose the simpler aggregation deliberately.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these checks is in CI. Each is the kind of context the &lt;a class="link" href="https://explainanalyze.com/p/what-ai-gets-wrong-about-your-database/" &gt;first post in the AI series&lt;/a&gt; describes: information the schema doesn&amp;rsquo;t carry, that lives in the engineer&amp;rsquo;s head, that the model has no way to read. That post argues for putting as much of that context back into the catalog as possible: declared FKs, column comments, named constraints, conventions. The work raises the floor; it doesn&amp;rsquo;t change what&amp;rsquo;s true here. Even with a perfectly described catalog, no schema declaration says &amp;ldquo;this team&amp;rsquo;s rule is to filter soft-deletes on every joined table&amp;rdquo; or &amp;ldquo;the previous version of this query had a JOIN cardinality bug, don&amp;rsquo;t repeat it.&amp;rdquo; Some classes of risk only exist at runtime against real data, real conventions, and real history, and the test suite is the only place those conditions can be reproduced before production.&lt;/p&gt;
&lt;h2 id="confidence-is-anti-signal"&gt;Confidence is anti-signal
&lt;/h2&gt;&lt;p&gt;The dangerous dynamic isn&amp;rsquo;t that the model gets things wrong. It&amp;rsquo;s that it sounds most confident exactly where it&amp;rsquo;s most likely wrong. Ask for a basic &lt;code&gt;SELECT&lt;/code&gt; with a &lt;code&gt;JOIN&lt;/code&gt; and the output is right, no hedging. Ask for NULL semantics in an outer join, timezone arithmetic across a DST boundary, isolation-level behavior under contention, dialect-specific JSON path syntax, or a window-function frame clause, and the output reads with the same calm tone. The probability of correctness has dropped; the prose around it hasn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;Human reviewers anchor on tone. There&amp;rsquo;s no visual signal in the diff that says &amp;ldquo;this part lives in a region where the training data is sparse and contradictory.&amp;rdquo; A query that drops rows because of an unintended tie in &lt;code&gt;ORDER BY created_at LIMIT 100 OFFSET 200&lt;/code&gt; reads exactly like a query that doesn&amp;rsquo;t. A &lt;code&gt;CHECK&lt;/code&gt; that lists valid values but doesn&amp;rsquo;t constrain transitions reads exactly like one that does. The reviewer&amp;rsquo;s internal calibration (&amp;ldquo;this part looked sketchy, I&amp;rsquo;ll dig&amp;rdquo;) is fed by uncertainty cues that the model doesn&amp;rsquo;t emit. &amp;ldquo;Review more carefully&amp;rdquo; cannot fix this, because the things worth scrutinizing don&amp;rsquo;t look worth scrutinizing.&lt;/p&gt;
&lt;h2 id="volume-changes-the-math"&gt;Volume changes the math
&lt;/h2&gt;&lt;p&gt;The bugs above existed before AI. What changed is the rate. A team that used to ship eight migrations a week now ships eighty, because the cost of writing one collapsed and the cost of reviewing one didn&amp;rsquo;t. The reviewer who used to give each migration ten minutes now gets one; the senior engineer who would have caught the soft-delete bug on her own change has eight more changes in queue waiting for the same scrutiny. The implicit human review didn&amp;rsquo;t disappear, it got rationed.&lt;/p&gt;
&lt;p&gt;The acute version of this is the wrong query that ships to production. The chronic version is schema drift, and it&amp;rsquo;s worse. Naming conventions decay because nobody enforces them on every PR. Redundant indexes accumulate because the model suggested one without checking what already exists. &lt;code&gt;NOT NULL&lt;/code&gt; constraints get dropped because the migration was failing and removing the constraint made it pass. Soft deletes get reinvented every quarter because each AI session starts fresh on the team&amp;rsquo;s conventions, and &amp;ldquo;fresh&amp;rdquo; plus &amp;ldquo;confident&amp;rdquo; plus &amp;ldquo;eighty PRs a week&amp;rdquo; is how a codebase ends up with three different ways to mark a row deleted, all in production.&lt;/p&gt;
&lt;p&gt;None of this is catastrophic in any given week. The compounding cost shows up eighteen months later, when query plans degrade, an ORM upgrade exposes the inconsistencies, or a new engineer can&amp;rsquo;t tell which of three &amp;ldquo;current&amp;rdquo; patterns to follow. The test suite isn&amp;rsquo;t only catching the acute incident; it&amp;rsquo;s the artifact that resists the drift, because every assertion the team writes is a convention written down where the next session can&amp;rsquo;t unlearn it.&lt;/p&gt;
&lt;p&gt;Murphy&amp;rsquo;s Law used to apply intermittently. With AI in the loop, it applies 100% of the time. Every weak corner of the test suite gets exercised on a weekly cadence, and what used to be a rare edge case becomes the load-bearing case.&lt;/p&gt;
&lt;h2 id="the-failure-mode-is-broader-than-wrong-queries"&gt;The failure mode is broader than wrong queries
&lt;/h2&gt;&lt;p&gt;The soft-delete bug is one shape. The same dynamic produces every other class of database failure AI introduces:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Migrations that lock production at scale.&lt;/strong&gt; Asked to add a &lt;code&gt;tier&lt;/code&gt; column to &lt;code&gt;users&lt;/code&gt;, the model produces &lt;code&gt;ALTER TABLE users ADD COLUMN tier TINYINT NOT NULL DEFAULT 0&lt;/code&gt;. CI&amp;rsquo;s empty-database migration test runs in 200ms and passes; in production, MySQL rewrites all 50 million rows under a metadata lock for 40 minutes during business hours. The DDL is syntactically valid; the failure is volumetric, and CI is structurally incapable of seeing it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;JOIN cardinality bugs that produce plausible numbers.&lt;/strong&gt; An AI-generated &lt;code&gt;LEFT JOIN&lt;/code&gt; with a predicate placed in the &lt;code&gt;WHERE&lt;/code&gt; rather than the &lt;code&gt;ON&lt;/code&gt; clause filters out the unmatched side; a JOIN through a bridge table without composite &lt;code&gt;UNIQUE&lt;/code&gt; multiplies aggregations. The result set has the right shape, the row count is plausible, the number is wrong. Catching it requires a test that asserts the result against a known dataset where the failure mode would change the count.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Upserts assuming undeclared constraints.&lt;/strong&gt; &lt;code&gt;ON CONFLICT (email) DO UPDATE&lt;/code&gt; is correct only if &lt;code&gt;email&lt;/code&gt; actually has a &lt;code&gt;UNIQUE&lt;/code&gt; constraint. Without it, PostgreSQL throws the moment the planner sees no usable arbiter index; in some MySQL configurations, the equivalent &lt;code&gt;INSERT ... ON DUPLICATE KEY UPDATE&lt;/code&gt; silently inserts duplicates because there&amp;rsquo;s no key to conflict on. The model writes the upsert from the column name and the question, doesn&amp;rsquo;t check the catalog for the constraint, and the test database has no concurrent writers exercising the path.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CHECK constraints that look right and aren&amp;rsquo;t.&lt;/strong&gt; Asked to &amp;ldquo;prevent transitioning from &lt;code&gt;cancelled&lt;/code&gt; to &lt;code&gt;active&lt;/code&gt;,&amp;rdquo; the model emits &lt;code&gt;CHECK (status IN ('pending','active','cancelled'))&lt;/code&gt;, which lists the valid values but doesn&amp;rsquo;t constrain transitions. The constraint is syntactically right, semantically wrong, and indistinguishable from a working constraint until an &lt;code&gt;UPDATE&lt;/code&gt; in production succeeds where it shouldn&amp;rsquo;t.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Indexes the planner will ignore.&lt;/strong&gt; Asked to speed up &lt;code&gt;WHERE LOWER(email) = ?&lt;/code&gt;, the model adds &lt;code&gt;CREATE INDEX ON users (email)&lt;/code&gt;. The planner can&amp;rsquo;t use it for the function call; the query stays slow; the EXPLAIN was never inspected because the index was the visible &amp;ldquo;fix.&amp;rdquo;&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Every one of these is detectable. None of them is detected by the kind of test most teams run.&lt;/p&gt;
&lt;h2 id="what-test-the-database-actually-means-today"&gt;What &amp;ldquo;test the database&amp;rdquo; actually means today
&lt;/h2&gt;&lt;p&gt;For most teams, &amp;ldquo;we test the database&amp;rdquo; means one of two things:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;The application&amp;rsquo;s unit tests use a real database (often SQLite or an empty Postgres) for fixtures, and they pass.&lt;/li&gt;
&lt;li&gt;CI runs &lt;code&gt;db:migrate&lt;/code&gt; against a fresh empty database before the test suite, and it doesn&amp;rsquo;t error.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;Neither is testing the database. The first tests the application&amp;rsquo;s happy path with a database underneath. The second tests migration syntax. Both leave the entire surface above untested. The class of failure AI introduces - semantically valid SQL that misbehaves under realistic conditions - passes both checks every time.&lt;/p&gt;
&lt;p&gt;What&amp;rsquo;s missing is a layer of tests that asserts against the database&amp;rsquo;s actual behavior on representative data: the migration finishes within a duration budget; the constraint rejects the values it claims to; the query, run against a known fixture, returns the expected count and the expected aggregate; the index in the EXPLAIN is actually used; the schema invariants the team thinks they have are present and behave the way the team thinks they do. These categories of test exist. Tools for each have existed for years. Most teams have never written them. Pre-AI, the teams that didn&amp;rsquo;t write them got away with it because the engineer writing each migration and each query was applying the checks in their head: slowly, with friction, sometimes wrong, but applying them. Post-AI, that engineer is increasingly not in the loop, and the only thing left is what&amp;rsquo;s actually written down.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://explainanalyze.com/p/testing-your-database-part-2-what-to-test-and-how/" &gt;Part 2&lt;/a&gt; covers what to test in each category and the tools that exist for each.&lt;/p&gt;
&lt;h2 id="when-this-doesnt-apply"&gt;When this doesn&amp;rsquo;t apply
&lt;/h2&gt;&lt;p&gt;The argument is conditional: if AI is writing any portion of your data layer, you need this. Cases where the conditional doesn&amp;rsquo;t hold:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;AI isn&amp;rsquo;t writing your migrations or queries.&lt;/strong&gt; The team uses AI for application-level boilerplate; SQL and schema changes are still hand-written. The implicit human review is intact for the data layer, and the case for tests is the same as it was before.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The database is small and single-writer.&lt;/strong&gt; A 200-row admin table maintained by one service. Lock duration on &lt;code&gt;ALTER&lt;/code&gt; is microseconds, JOIN cardinality is unambiguous, the surface AI can hurt is small enough to manage by reading every diff.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The work is read-only and a domain expert validates each result.&lt;/strong&gt; An analyst uses AI to draft queries and reviews each result against expectations. The AI is generating drafts, not shipping queries. The domain expert is the test, the same way the senior engineer used to be.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The data is throwaway.&lt;/strong&gt; Dev environments, ephemeral analytics, throwaway scripts. The cost of being wrong is low and the failures are reversible.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;For everything else (production-facing systems, multi-writer schemas, data the business depends on) the conditional holds.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;Automation moves verification work; it doesn&amp;rsquo;t eliminate it. Pre-AI, &amp;ldquo;we don&amp;rsquo;t really test the database&amp;rdquo; worked for most teams because the engineer writing each migration and each query was the test, applying dozens of unwritten checks per change. The check was slow, expensive, and unreliable, but it existed. Post-AI, the engineer is increasingly not in the loop, and the team&amp;rsquo;s database verification is whatever the test suite explicitly asserts. The useful choice isn&amp;rsquo;t whether to verify; it&amp;rsquo;s whether to verify in CI or in production.&lt;/p&gt;
&lt;p&gt;The picture worsens with agents in the loop. An agent that runs migrations, executes restores, or modifies schemas under its own permissions doesn&amp;rsquo;t have an implicit human review even at the moment of execution. A model that&amp;rsquo;s right 99.9% of the time, given a thousand operations a week, ships a serious incident every two weeks (and &amp;ldquo;right 99.9%&amp;rdquo; is generous). The test suite is what makes any of that safe; the cost of building it is a fraction of the cost of one production incident, and AI made the math explicit.&lt;/p&gt;
&lt;p&gt;&lt;a class="link" href="https://explainanalyze.com/p/testing-your-database-part-2-what-to-test-and-how/" &gt;Part 2&lt;/a&gt; covers what each layer has to assert and the frameworks (Squawk, pgTAP, Testcontainers, lock-duration probes, result-regression diffs) that already exist for each category.&lt;/p&gt;</description></item><item><title>What AI Gets Wrong About Your Database</title><link>https://explainanalyze.com/p/what-ai-gets-wrong-about-your-database/</link><pubDate>Tue, 03 Feb 2026 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/what-ai-gets-wrong-about-your-database/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post What AI Gets Wrong About Your Database" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;LLMs generate SQL from the catalog (column names, types, whatever constraints have been declared), and the silent-failure rate is set by the gap between what the schema describes and what the database actually contains. Close the gap with declared FKs, column comments, named constraints, and enforced conventions: the same work that makes the schema describe itself to any reader.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;An analyst asks the assistant for total revenue per enterprise customer for Q1. The model reads the catalog (&lt;code&gt;customers&lt;/code&gt;, &lt;code&gt;orders&lt;/code&gt;, &lt;code&gt;order_items&lt;/code&gt;, &lt;code&gt;subscriptions&lt;/code&gt;), generates a four-table JOIN, applies what looks like the right &lt;code&gt;status = 1&lt;/code&gt; filter on subscriptions and a &lt;code&gt;created_at &amp;gt;= '2026-01-01'&lt;/code&gt; predicate, and returns a number. $4.2M.&lt;/p&gt;
&lt;p&gt;The number is $1.4M too high. &lt;code&gt;order_items&lt;/code&gt; is joined through a promotions bridge that multiplies rows for any order with a stacked discount, and the bridge has no UNIQUE constraint that would have stopped the multiplication. &lt;code&gt;status = 1&lt;/code&gt; on &lt;code&gt;subscriptions&lt;/code&gt; means &amp;ldquo;pending,&amp;rdquo; not &amp;ldquo;active&amp;rdquo; (the column is a TINYINT reused with different semantics across tables, with no comment to disambiguate). The date filter only constrains &lt;code&gt;subscriptions.created_at&lt;/code&gt;, so historical orders attach to current subscriptions. The query ran in 80ms. &lt;code&gt;EXPLAIN&lt;/code&gt; looked clean. The result set had the right shape. Nothing about it said it was wrong.&lt;/p&gt;
&lt;p&gt;A senior engineer who knows the schema would have caught all three. They know the bridge multiplies rows, they know &lt;code&gt;status&lt;/code&gt; is overloaded across tables, they know which &lt;code&gt;created_at&lt;/code&gt; belongs to which entity. The model has none of that internal context. It has only what the catalog tells it. The rest of this post is about what&amp;rsquo;s missing from the catalog and how to put it back, so the model gets the same affordances the senior engineer relies on.&lt;/p&gt;
&lt;p&gt;The obvious fix is &amp;ldquo;give the model more context: connect MCP, dump the schema into the prompt, fine-tune on the company&amp;rsquo;s queries.&amp;rdquo; Each helps at the margin. None of them closes the gap in the opening scenario, because the model&amp;rsquo;s mistake wasn&amp;rsquo;t a context-window problem. It was working from a description of the database that didn&amp;rsquo;t contain the information it needed. Even a perfect, fully-loaded schema describes the contract the database enforces on writes, not the meaning the data carries or the parts of it that live entirely outside the catalog. Every model also has a hallucination floor against any prompt, no matter how complete: asked for &lt;code&gt;customers.email&lt;/code&gt;, it&amp;rsquo;ll sometimes produce &lt;code&gt;customer_email&lt;/code&gt; because that&amp;rsquo;s the more common pattern in the training data; asked to join through a bridge, it&amp;rsquo;ll sometimes invent a column that doesn&amp;rsquo;t exist. More context lowers the rate; it doesn&amp;rsquo;t drive it to zero.&lt;/p&gt;
&lt;p&gt;This post is the index of where those gaps live. Each issue gets a brief description and a link to the dedicated post that covers the mechanics in full. The filter throughout: a knowledgeable human handles it because of context that isn&amp;rsquo;t in the schema. The same context, encoded in the catalog, is what the model needs.&lt;/p&gt;
&lt;h2 id="caveat-first-the-verification-gap-nothing-in-this-article-fixes"&gt;Caveat first: the verification gap nothing in this article fixes
&lt;/h2&gt;&lt;p&gt;Before any of the layers, the ceiling. Application code has a build step. The type checker rejects bad assignments, the compiler rejects ill-formed programs, unit tests run in CI, integration tests catch runtime mismatches. If it compiles and the tests pass, the mistake is more likely in the test than in the code. The default assumption is that the pipeline would have caught an obvious bug. SQL has none of that. The parser accepts any syntactically valid query, regardless of whether it matches the question the human was trying to answer. &lt;code&gt;EXPLAIN&lt;/code&gt; tells you the plan&amp;rsquo;s cost, not whether the predicates are aimed at the right columns. The database compiles, runs, and returns a result whether the query is right or not.&lt;/p&gt;
&lt;p&gt;A handful of teams write meaningful tests for their queries (dbt tests, Soda checks, data-diff regressions, assertions against known inputs on every PR). Most don&amp;rsquo;t. Most production SQL has no test coverage in the sense application code does: no &amp;ldquo;given this, expect that&amp;rdquo; assertion, no diff against last week&amp;rsquo;s numbers on a representative dataset, no guard that fails a PR when a predicate changes the result set in an unexpected way. Code review is the verification step, and code review on SQL is usually &amp;ldquo;does this look right&amp;rdquo;, which is exactly the check AI-generated output is designed to pass.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the ceiling. The reviewer who can catch a wrong AI-generated query is, by definition, the reviewer who already knew enough to write the right query, which is exactly the value the model was supposed to provide. A richer catalog narrows the window where this matters: more constraints to violate, more types to mismatch, more comments to flag a wrong predicate. It doesn&amp;rsquo;t close the window. Everything below is about pushing the floor up under that ceiling.&lt;/p&gt;
&lt;h2 id="a-note-on-what-reads-the-catalog-actually-means"&gt;A note on what &amp;ldquo;reads the catalog&amp;rdquo; actually means
&lt;/h2&gt;&lt;p&gt;The model doesn&amp;rsquo;t always read the catalog on the first attempt. With most setups (Copilot in the IDE, ChatGPT with a database tool, MCP-backed agents) the first pass is pattern-matching against the question, the table names mentioned in chat, and whatever the training data suggests. The catalog gets queried lazily, often only after a syntax error sends the model back for another try. Silent-failure SQL never errors, so the catalog never gets read at all.&lt;/p&gt;
&lt;p&gt;Everything below describes what&amp;rsquo;s missing when the model reads the catalog. In practice, the failure rate is higher than the layers alone predict, because the catalog is the floor of what the model could see, not the baseline of what it actually sees. The fixes still apply. A richer catalog at least gives the model something useful when it does read.&lt;/p&gt;
&lt;h2 id="three-layers-the-catalog-can-fix"&gt;Three layers the catalog can fix
&lt;/h2&gt;&lt;h3 id="1-relationships---how-tables-connect"&gt;1. Relationships - how tables connect
&lt;/h3&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Missing foreign keys.&lt;/strong&gt; Without declared FKs, the only signal connecting tables is column-name matching. That works for &lt;code&gt;user_id → users.id&lt;/code&gt; and breaks on the column vocabulary every legacy schema accumulates: &lt;code&gt;creator_id&lt;/code&gt;, &lt;code&gt;modified_by&lt;/code&gt;, &lt;code&gt;owner&lt;/code&gt;, &lt;code&gt;assigned_to&lt;/code&gt;, &lt;code&gt;ref_id&lt;/code&gt;, &lt;code&gt;parent&lt;/code&gt;. The FK is the one machine-readable statement of how tables actually connect, and every assistant that reads &lt;code&gt;information_schema&lt;/code&gt; falls back to guessing without it. &lt;a class="link" href="https://explainanalyze.com/p/foreign-keys-are-not-optional/" &gt;Foreign Keys Are Not Optional&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bare &lt;code&gt;id&lt;/code&gt; primary keys.&lt;/strong&gt; &lt;code&gt;table_a.id = table_b.id&lt;/code&gt; is syntactically valid SQL between every pair of tables in the database, so the model can construct nonsense joins that return rows. With mixed PK strategies coexisting (older services on BIGINT AUTO_INCREMENT, newer ones on CHAR(36) UUID), joining a UUID &lt;code&gt;id&lt;/code&gt; to a BIGINT &lt;code&gt;id&lt;/code&gt; silently casts in MySQL and returns zero rows or false matches with no error. &lt;a class="link" href="https://explainanalyze.com/p/the-bare-id-primary-key-when-every-table-joins-to-every-other-table/" &gt;Generic &lt;code&gt;id&lt;/code&gt; Primary Keys&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Polymorphic references.&lt;/strong&gt; A &lt;code&gt;resource_id&lt;/code&gt; column whose target table depends on a sibling &lt;code&gt;resource_type&lt;/code&gt; discriminator (&lt;code&gt;'order'&lt;/code&gt; → &lt;code&gt;orders&lt;/code&gt;, &lt;code&gt;'invoice'&lt;/code&gt; → &lt;code&gt;invoices&lt;/code&gt;) can&amp;rsquo;t be enforced as an FK and looks like a normal column. The correct query needs a conditional JOIN or UNION pattern the model won&amp;rsquo;t generate without being told. &lt;a class="link" href="https://explainanalyze.com/p/polymorphic-references-are-not-foreign-keys/" &gt;Polymorphic References&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;TEXT/JSON columns.&lt;/strong&gt; Column type &lt;code&gt;JSON&lt;/code&gt; says nothing about the keys inside; the actual shape lives in a serializer class six repos away. JSON_EXTRACT paths the model writes from the column name and the question match zero rows once the producer renamed a key two years ago, and old generations of payloads coexist in the same column with no version field to dispatch on. &lt;a class="link" href="https://explainanalyze.com/p/text-and-json-columns-where-the-schema-goes-to-hide/" &gt;TEXT and JSON Columns&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cross-database references.&lt;/strong&gt; A service whose &lt;code&gt;account_id&lt;/code&gt; points at &lt;code&gt;alpha.businesses.id&lt;/code&gt; in another schema is invisible to a model scoped to one connection (the default for most MCP setups). The reference exists in application code or in views, not in the catalog of either database, so neither end describes it.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="2-meaning---what-values-actually-mean"&gt;2. Meaning - what values actually mean
&lt;/h3&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Polysemic types and data drift.&lt;/strong&gt; &lt;code&gt;TINYINT NOT NULL&lt;/code&gt; accepts &lt;code&gt;1&lt;/code&gt; meaning &amp;ldquo;active&amp;rdquo; in one table, &amp;ldquo;pending&amp;rdquo; in another, &amp;ldquo;has been processed&amp;rdquo; in a third. Soft-delete coverage is partial across tables; VARCHAR dates carry multiple format generations in the same column; sentinel rows like &lt;code&gt;user_id = 0&lt;/code&gt; for &amp;ldquo;anonymous&amp;rdquo; or &lt;code&gt;email = 'DO_NOT_USE@test.com'&lt;/code&gt; get treated as real data. Copilot ranked the test row as the top customer with $99,999 in revenue because it had the highest total. Visible to anyone querying the table for years, invisible to anyone reading only the DDL. &lt;a class="link" href="https://explainanalyze.com/p/reading-the-schema-is-not-reading-the-data/" &gt;Reading the Schema Is Not Reading the Data&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Legacy schema drift.&lt;/strong&gt; &lt;code&gt;tmp_orders&lt;/code&gt; is the main orders table; &lt;code&gt;old_price&lt;/code&gt; is the current price; &lt;code&gt;flag1&lt;/code&gt; means something nobody remembers. The model reasons from the names (&amp;ldquo;this is staging, prefer the non-tmp table; this is historical, ignore for current queries&amp;rdquo;) and each reasonable inference is wrong in this specific schema. &lt;a class="link" href="https://explainanalyze.com/p/legacy-schemas-are-sediment-not-design/" &gt;Legacy Schemas Are Sediment&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Missing column comments.&lt;/strong&gt; The lowest-cost fix in the entire schema-as-context surface, and almost universally absent. &lt;code&gt;status TINYINT COMMENT 'Order lifecycle: 1=pending, 2=processing, 3=shipped, 4=delivered, 5=cancelled'&lt;/code&gt; is the difference between a model that knows and a model that guesses, and adding it is a pure-metadata operation with zero downtime. &lt;a class="link" href="https://explainanalyze.com/p/comment-your-schema/" &gt;Comment Your Schema&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;NULL semantics.&lt;/strong&gt; The catalog says a column is nullable; it doesn&amp;rsquo;t say whether NULL means &amp;ldquo;unset,&amp;rdquo; &amp;ldquo;not applicable,&amp;rdquo; &amp;ldquo;still in progress,&amp;rdquo; or &amp;ldquo;data lost during the 2019 migration.&amp;rdquo; A knowledgeable human knows what NULL signifies on each column from context that lives outside the catalog; the model has no such reflex and writes predicates that work for the non-null path. The fix is the same as polysemic types: encode the meaning in a comment. &lt;a class="link" href="https://explainanalyze.com/p/null-in-sql-three-valued-logic-and-the-silent-bug-factory/" &gt;NULL and Three-Valued Logic&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Business rules outside the schema.&lt;/strong&gt; &amp;ldquo;Active customer&amp;rdquo; is &lt;code&gt;status = 'active'&lt;/code&gt; to one team, &lt;code&gt;last_login &amp;gt; 90 days ago&lt;/code&gt; to another, &lt;code&gt;account_balance &amp;gt; 0&lt;/code&gt; to a third. Discount logic, approval workflows, regulatory carve-outs all live in application code, queue workers, or a Confluence page. The fraction the model can read is whichever fraction the team chose to put in the database. Encoded as CHECK constraints, generated columns, views, or stored procedures, the rule becomes part of the schema and visible to every reader. &lt;a class="link" href="https://explainanalyze.com/p/where-business-logic-lives-database-vs.-application/" &gt;Where Business Logic Lives&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Schema as the source of truth.&lt;/strong&gt; The catalog is only useful as self-documentation if it&amp;rsquo;s the source of truth. ORM-heavy codebases split the data model across the migration, model class, serializer, fixtures, and any query helpers, and the version the model class describes can drift from what the schema actually enforces (CHECK constraints the model class doesn&amp;rsquo;t know about, triggers that mutate after insert, generated columns treated as regular fields). Schema-first tools (sqlc, Drizzle, jOOQ) keep the database authoritative; ORM-first frameworks bury constraints in code the model has no signal to read. &lt;a class="link" href="https://explainanalyze.com/p/orms-are-a-coupling-not-an-abstraction/" &gt;ORMs Are a Coupling&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Inconsistent conventions.&lt;/strong&gt; &lt;code&gt;userId&lt;/code&gt;, &lt;code&gt;user_id&lt;/code&gt;, and &lt;code&gt;UserID&lt;/code&gt; referring to the same entity across tables built by different teams in different eras. Mixed PK strategies, partial soft-delete adoption, ambiguous boolean prefixes. Every inconsistency forces the model to guess which variant each table uses, and the senior engineer who knows the per-era convention from history is the only one closing the gap. &lt;a class="link" href="https://explainanalyze.com/p/schema-conventions-dont-survive-without-automation/" &gt;Schema Conventions and Why They Matter&lt;/a&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;h3 id="3-integrity---what-the-catalog-actually-enforces"&gt;3. Integrity - what the catalog actually enforces
&lt;/h3&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Missing UNIQUE constraints.&lt;/strong&gt; A many-to-many bridge table without a composite UNIQUE silently inflates aggregations on join. That&amp;rsquo;s the row-multiplication failure in the opening scenario. &lt;code&gt;ON CONFLICT (email) DO UPDATE&lt;/code&gt; only works if &lt;code&gt;email&lt;/code&gt; actually has a declared UNIQUE constraint; without it, behavior is undefined or throws. The constraints exist in the team&amp;rsquo;s heads (&amp;ldquo;these can&amp;rsquo;t repeat&amp;rdquo;) but not in the catalog, so the database can&amp;rsquo;t enforce them and the model can&amp;rsquo;t read them. &lt;a class="link" href="https://explainanalyze.com/p/joins-that-lie-the-cardinality-problem/" &gt;Join Cardinality Silent Bugs&lt;/a&gt;; &lt;a class="link" href="https://explainanalyze.com/p/uniqueness-and-selectivity-the-two-numbers-that-drive-query-plans/" &gt;Uniqueness and Selectivity&lt;/a&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Type, charset, and collation drift.&lt;/strong&gt; Two &lt;code&gt;VARCHAR(50)&lt;/code&gt; columns with the same name in different tables can have different charsets (&lt;code&gt;utf8mb4&lt;/code&gt; vs &lt;code&gt;latin1&lt;/code&gt;) or collations (&lt;code&gt;utf8mb4_general_ci&lt;/code&gt; vs &lt;code&gt;utf8mb4_0900_ai_ci&lt;/code&gt;), causing joins to silently break equality or fall back to per-row conversion. The information is in &lt;code&gt;information_schema.COLUMNS&lt;/code&gt;, but it&amp;rsquo;s a column nobody reads. The senior engineer knows the charset history from the migrations they were around for; the model has no reflex to check. The self-doc fix is enforcing one charset and one collation per database and documenting (or migrating away from) the legacy pockets. &lt;a class="link" href="https://explainanalyze.com/p/schema-conventions-dont-survive-without-automation/" &gt;Schema Conventions&lt;/a&gt; covers the enforcement side.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="what-actually-helps"&gt;What actually helps
&lt;/h2&gt;&lt;p&gt;The leverage is in making the catalog a richer description of the database, so the model has more to read and the database has more to enforce. Each lever has a real cost; none is free.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Declare the relationships.&lt;/strong&gt; &lt;a class="link" href="https://explainanalyze.com/p/foreign-keys-are-not-optional/" &gt;FKs&lt;/a&gt; are the highest-leverage single fix; every assistant that reads &lt;code&gt;information_schema&lt;/code&gt; immediately gets the join graph. Cost: orphan cleanup on long-lived tables.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Comment the columns.&lt;/strong&gt; &lt;a class="link" href="https://explainanalyze.com/p/comment-your-schema/" &gt;The single largest gain in benchmarked LLM SQL accuracy&lt;/a&gt; comes from semantic descriptions next to the schema. Pure metadata, zero downtime, almost universally absent.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Constrain the writers.&lt;/strong&gt; CHECK, UNIQUE, and NOT NULL are facts the model can read and classes of bad query the database will reject. &lt;a class="link" href="https://explainanalyze.com/p/uniqueness-and-selectivity-the-two-numbers-that-drive-query-plans/" &gt;Composite uniqueness on bridge tables&lt;/a&gt; prevents the multiplication failure in the opening scenario.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Promote what gets queried out of the blob.&lt;/strong&gt; &lt;a class="link" href="https://explainanalyze.com/p/text-and-json-columns-where-the-schema-goes-to-hide/" &gt;JSON keys that drive most filters&lt;/a&gt; belong in real columns; generated columns are the low-friction path.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Pick conventions and enforce them.&lt;/strong&gt; &lt;a class="link" href="https://explainanalyze.com/p/schema-conventions-dont-survive-without-automation/" &gt;Naming, PK strategy, charset, soft-delete pattern&lt;/a&gt;; one of each per database, linted on every migration.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Move business rules into the schema where it makes sense.&lt;/strong&gt; &lt;a class="link" href="https://explainanalyze.com/p/where-business-logic-lives-database-vs.-application/" &gt;CHECK constraints, generated columns, views&lt;/a&gt; make the team&amp;rsquo;s definitions readable to every consumer of the database, not only the service that owns them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Treat AI-generated SQL as external input.&lt;/strong&gt; Profile the columns in the predicates against the actual data before the query ships. &lt;code&gt;SELECT col, COUNT(*) FROM t GROUP BY col ORDER BY 2 DESC LIMIT 20&lt;/code&gt; catches polysemic-TINYINT and sentinel-value mistakes in seconds. For aggregations, sanity-check against an order-of-magnitude expectation.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Read every statement before shipping it. Don&amp;rsquo;t vibe-code production SQL.&lt;/strong&gt; If you can&amp;rsquo;t explain why this &lt;code&gt;LEFT JOIN&lt;/code&gt; is &lt;code&gt;LEFT&lt;/code&gt; rather than &lt;code&gt;INNER&lt;/code&gt;, why this column is in the &lt;code&gt;GROUP BY&lt;/code&gt;, or why the predicate is &lt;code&gt;status = 1&lt;/code&gt; instead of &lt;code&gt;status IN (1, 2)&lt;/code&gt;, you&amp;rsquo;re trusting the model&amp;rsquo;s understanding instead of your own. The catalog work above gives the model better signal; it doesn&amp;rsquo;t replace the human review where each clause has to be read, understood, and justified before the query goes near production.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="what-self-documentation-doesnt-fix"&gt;What self-documentation doesn&amp;rsquo;t fix
&lt;/h2&gt;&lt;p&gt;Improving the catalog closes most of the silent-failure surface. Four classes of concern persist regardless of how well-described the schema is, flagged here so the article isn&amp;rsquo;t read as overclaiming.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Protocol and integration gaps.&lt;/strong&gt; MCP and other text-to-SQL connectors have their own reliability holes: context-window truncation, no standard error contract, tool definitions that change after confirmation. Mitigation lives in the integration, not the schema.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Agentic blast radius.&lt;/strong&gt; Read-only assistants are one risk profile; agents that can run DDL or arbitrary writes are another. The Replit incident in 2025 (deleted production data, generated 4,000 fake users to cover it up) was an authorization failure, not a schema failure. Lever: read-only credentials and audit trails.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Comprehension debt.&lt;/strong&gt; &lt;a class="link" href="https://www.anthropic.com/research/AI-assistance-coding-skills" target="_blank" rel="noopener"
 &gt;Engineers using AI assistance score measurably lower on comprehension quizzes about the code they shipped&lt;/a&gt;. A perfect catalog doesn&amp;rsquo;t help if the team has lost the mental model of what they&amp;rsquo;re maintaining.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Adversarial inputs.&lt;/strong&gt; Text-to-SQL is sensitive to crafted prompts that produce malicious SQL. Mitigation is read-only credentials, query parsers, row-limit caps. Not richer schema metadata.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="when-the-schema-only-model-is-fine"&gt;When the schema-only model is fine
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Greenfield schemas with strict conventions.&lt;/strong&gt; A six-month-old service database with FKs everywhere, every column commented, every enum an ENUM, every date &lt;code&gt;TIMESTAMPTZ&lt;/code&gt;. The drift hasn&amp;rsquo;t accumulated and the conventions are linted on every migration.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Curated demo databases.&lt;/strong&gt; Sakila, Northwind, Chinook. AI performs dramatically better on these than on any production schema, and benchmarks run on them aren&amp;rsquo;t predictive of production performance.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Read-only exploration with a domain-aware human in the loop.&lt;/strong&gt; The model writes the query; the human reads the result and recognizes the wrong answer. The mistake is treating the model&amp;rsquo;s output as an answer rather than as a draft.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Single-team, single-database workloads.&lt;/strong&gt; Twenty tables, three engineers, one service writing every row. The model has less to get wrong because there&amp;rsquo;s less schema to read. Grow the team or the schema by an order of magnitude and the math flips.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;A production database is the smallest version of itself in &lt;code&gt;information_schema&lt;/code&gt;. The catalog is what was declared; the database is what&amp;rsquo;s actually in it. Every gap between the two is a place where a knowledgeable human carries the missing context in their head and an LLM produces plausible-shaped wrong answers (relationships that aren&amp;rsquo;t constraints, meaning that isn&amp;rsquo;t named, integrity rules that live in code instead of in the schema). None of these failures are unique to AI. Humans hit them too, more slowly and with more friction, and the friction is what gives experienced engineers a chance to notice. AI removes the friction without closing the gaps the friction was compensating for.&lt;/p&gt;
&lt;p&gt;The lever is making the catalog a richer description: declared FKs, column comments, CHECK and UNIQUE constraints, conventional naming, generated columns where the JSON gets queried. The schema describes itself and the database enforces what it describes. Each investment pays off whether or not LLMs are in the loop. The schema gets more useful to every reader, the integrity gets more enforced, and the part of the database that lives in tribal knowledge shrinks. AI is the forcing function that makes the cost of skipping any of this immediately visible.&lt;/p&gt;</description></item><item><title>The Hello-World Procurement Problem: Why LLM Tooling Gets Bought Wrong</title><link>https://explainanalyze.com/p/the-hello-world-procurement-problem-why-llm-tooling-gets-bought-wrong/</link><pubDate>Sun, 21 Dec 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/the-hello-world-procurement-problem-why-llm-tooling-gets-bought-wrong/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post The Hello-World Procurement Problem: Why LLM Tooling Gets Bought Wrong" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;A CTO declares &amp;ldquo;full agentic&amp;rdquo; off a vendor demo. Without an SME watching the rollout, corruption ships and surfaces a year later when a customer reports a wrong number. With an SME, the work is information infrastructure first (so agents have enough context to make high-probability decisions) and guardrails for the cases where context isn&amp;rsquo;t enough.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;A CTO sits through a vendor demo. A sales engineer types &amp;ldquo;show me the top ten customers by revenue last quarter&amp;rdquo; into a prompt and a working SQL query materializes in 30 seconds, runs against a sample dataset, returns plausible numbers. The CTO declares the company is going full agentic. Procurement closes the contract by Friday.&lt;/p&gt;
&lt;p&gt;Procurement closes on Friday. The first agents reach production Tuesday. By the end of the quarter, three internal dashboards are running LLM-generated SQL, two of them against the company&amp;rsquo;s order data. The SQL passes the lint check (&lt;code&gt;EXPLAIN&lt;/code&gt; runs, the result set has the right columns), and the dashboards display plausible numbers. Whether the numbers match what they should be is a question nobody on the team is positioned to answer.&lt;/p&gt;
&lt;h2 id="without-smes"&gt;Without SMEs
&lt;/h2&gt;&lt;p&gt;If the agent-generated SQL looks like gold to everyone in the room, the Rounders rule applies: if you can&amp;rsquo;t spot the sucker in your first half hour at the table, you are the sucker. Without someone in the room who&amp;rsquo;d catch the polysemic &lt;code&gt;tier&lt;/code&gt; column or the undocumented soft-delete convention buried in three tables, the team is approving plausibility on a system optimized to produce it.&lt;/p&gt;
&lt;p&gt;If the produced code looks good to you, you&amp;rsquo;re probably not the SME.&lt;/p&gt;
&lt;p&gt;The corruption rate observed in the demo is a lower bound for what the tool produces against real data, &lt;a class="link" href="https://explainanalyze.com/p/corruption-is-a-feature-not-a-bug-why-llms-corrupt-by-design/" &gt;often by a multiple&lt;/a&gt;. The realities catalogued in &lt;a class="link" href="https://explainanalyze.com/p/what-ai-gets-wrong-about-your-database/" &gt;What AI Gets Wrong About Your Database&lt;/a&gt; (undocumented conventions, polysemic columns, business logic in tribal knowledge, ten-year-old codebases with three &amp;ldquo;current&amp;rdquo; patterns) are exactly the regions of input space where the model&amp;rsquo;s training distribution is sparse and contradictory. Demos run in the dense-distribution sweet spot. Production runs the inverse on every axis.&lt;/p&gt;
&lt;p&gt;With nobody positioned to measure the gap, nothing flags it. Corruption is silent by construction. It doesn&amp;rsquo;t surface as one identifiable bug; it surfaces as drift across many places at once, traced back to LLM-generated code or queries whose authors can&amp;rsquo;t reconstruct what the model meant. By the time the rate is visible, the corruption has been propagating for weeks or months. The team has too many simultaneous issues to triage one at a time. Backups have rolled past the worst of the window.&lt;/p&gt;
&lt;p&gt;The detection mode is external. A customer reports a number that doesn&amp;rsquo;t match what they expected. An analyst running LLM-powered queries on the company&amp;rsquo;s data publishes a report that contradicts internal numbers. A regulator asks a question and the answer doesn&amp;rsquo;t match the previous quarter&amp;rsquo;s filing. Whatever surfaces it, the failure is now a public one, and the team learning the failure mode is the same team trying to contain it.&lt;/p&gt;
&lt;h2 id="with-smes"&gt;With SMEs
&lt;/h2&gt;&lt;p&gt;The CTO&amp;rsquo;s declaration doesn&amp;rsquo;t change. The job changes. With an SME watching the rollout, the work is infrastructure first.&lt;/p&gt;
&lt;p&gt;Agents make high-probability decisions when their inputs are dense. That means the schema is documented, polysemic columns are tagged, conventions are written down somewhere the model can reach, the dataset the agent runs against mirrors production rather than a curated subset. The realities that make a mature codebase mature (patterns evolved over years, decisions encoded in column names, exceptions buried in tribal knowledge) are exactly the inputs the agent doesn&amp;rsquo;t have unless someone puts them there. The SME&amp;rsquo;s first job is documenting what currently lives in heads. Without that, the agent operates in the sparse regions of its training distribution, and the floor on its corruption rate stays high regardless of how the harness is tuned.&lt;/p&gt;
&lt;p&gt;Guardrails are the second piece, for the cases where dense inputs still aren&amp;rsquo;t enough. Decompose work into chunks small enough to verify. Route checkpoints between chunks to the SME whose domain it is. Audits produce a failure-rate number against ground truth, not a yes/no. Recovery drills test rolling back six months of LLM-generated changes, because that&amp;rsquo;s the realistic detection horizon for silent corruption. The point is to catch the cases where the agent&amp;rsquo;s confidence and its accuracy are decoupled, which is where most of the corruption lives.&lt;/p&gt;
&lt;p&gt;Both pieces have to be in place before the deployment goes wide. Once the rate is visible from outside, the SME bench is already triaging incidents instead of building infrastructure, and the architecture won&amp;rsquo;t grow either piece on its own.&lt;/p&gt;
&lt;h2 id="when-this-doesnt-apply"&gt;When this doesn&amp;rsquo;t apply
&lt;/h2&gt;&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Small teams.&lt;/strong&gt; The buyer is the SME, or one degree away. The infrastructure question gets answered by the same person making the rollout call.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Bounded, low-stakes use cases.&lt;/strong&gt; Personal productivity tooling, draft generation, internal-only knowledge work where corruption is recoverable.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mature vendor categories.&lt;/strong&gt; Office suites, established CI/CD platforms, well-trodden CRM tooling. The failure modes are known and the buyer has reference points. New categories are where the asymmetry lives, and that&amp;rsquo;s exactly where LLM tooling sits in 2026.&lt;/li&gt;
&lt;/ul&gt;
&lt;h2 id="the-bill-arrives-later"&gt;The bill arrives later
&lt;/h2&gt;&lt;p&gt;The productivity dividend the CTO booked off the demo is real, in the sense that the deal closed and the harness shipped. The bill arrives a quarter or two later when a customer surfaces a number that doesn&amp;rsquo;t match the books, the auditor asks how the model arrived at it, and the team learns the failure mode in public instead of in QA.&lt;/p&gt;</description></item><item><title>Where Your Cloud Bill Actually Leaks: An Audit Nobody Runs</title><link>https://explainanalyze.com/p/where-your-cloud-bill-actually-leaks-an-audit-nobody-runs/</link><pubDate>Thu, 13 Nov 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/where-your-cloud-bill-actually-leaks-an-audit-nobody-runs/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Where Your Cloud Bill Actually Leaks: An Audit Nobody Runs" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;Cloud bills creep up because nobody owns bringing them down. The largest leaks are S3 versioning without a lifecycle policy, backup retention set when the database was a fraction of its current size, cross-AZ traffic on chatty services, lower environments running 24/7 at production sizes, and old workloads on instance generations the cloud now surcharges. An annual one-day audit by one engineer typically recovers a five-figure monthly sum and the savings stop being mysterious.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The S3 bill on a team&amp;rsquo;s data-lake bucket went from $1,400 a month to $9,800 over six months without anyone deploying anything new. The bucket had versioning enabled in 2022, no lifecycle policy, and a daily ETL job overwriting the same 40,000 objects every morning. Six months of overwrites left each object with roughly 180 versions in cold storage, and the storage charge was for all of them. Two hours on a one-page lifecycle policy reclaimed about $8,000 a month. The cost had been compounding for three and a half years; nobody had looked at the line item that broke it down.&lt;/p&gt;
&lt;p&gt;&amp;ldquo;Buy a FinOps tool&amp;rdquo; is the reflexive answer, and it&amp;rsquo;s half right. Cost tools surface the bill but don&amp;rsquo;t fix it. They tell you the storage line is up 40%; they don&amp;rsquo;t tell you which 40,000 objects are versioned 180 times, which dev environment has been running 24/7 since the previous CTO, or which AZ your chatty cache shares with. The savings live in walking the items.&lt;/p&gt;
&lt;h2 id="audit-storage-and-lifecycle-first"&gt;Audit storage and lifecycle first
&lt;/h2&gt;&lt;p&gt;Object storage with versioning enabled and no lifecycle policy is the most common large leak in any AWS account. S3 versioning charges for every version of every object indefinitely; a bucket with daily writes to the same keys can carry hundreds of versions per object after a year. The audit takes one query against the &lt;code&gt;s3:ListObjectVersions&lt;/code&gt; API or one tab in S3 Storage Lens. For buckets holding derived data (build artifacts, ETL outputs, logs with authoritative copies elsewhere), disable versioning entirely; that&amp;rsquo;s cheaper than running a lifecycle policy against it. For buckets that genuinely need versioning, a lifecycle rule expiring non-current versions after 30, 60, or 90 days reclaims most of the cost. Incomplete multipart uploads are the related sweep: failed uploads sit on the bucket forever unless a separate lifecycle rule clears them.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Check before you disable versioning&lt;/strong&gt;
 &lt;div&gt;Versioning is sometimes the only mechanism preventing data loss from an application bug, a misconfigured deletion policy, or a compliance retention requirement. A bucket that looks like &amp;ldquo;derived data&amp;rdquo; today might be the audit log a regulator asks about next year. Check the application&amp;rsquo;s recovery model and any compliance scope before turning it off.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Backup retention runs second. Most managed databases ship with a default of 7 days, and most teams later bumped it to 30 or 90 days &amp;ldquo;for safety&amp;rdquo; without revisiting whether the database was actually a fraction of its current size at the time. Snapshot storage above the database&amp;rsquo;s allocated size is billed separately at object-storage rates. A database that grew from 200 GB to 4 TB while retention stayed at 90 days has roughly 360 TB of snapshots on the line item, much of it for backups nobody has restored from. Cross-region snapshot replication, on by default in some compliance configurations, doubles that number. The conversation worth having is which databases need 90 days of point-in-time recovery and which only need 7. The answer is almost never &amp;ldquo;all of them&amp;rdquo;.&lt;/p&gt;
&lt;p&gt;Temporary and scratch storage is the third item. Buckets named &lt;code&gt;tmp-&lt;/code&gt;, &lt;code&gt;scratch-&lt;/code&gt;, &lt;code&gt;data-export-*&lt;/code&gt;, and &lt;code&gt;migration-2023-*&lt;/code&gt; get created for one-off jobs and never deleted. EFS file systems mounted for migration work that finished two years ago. Test datasets uploaded for vendor pitches nobody pursued. Logs shipped to a debugging bucket during last summer&amp;rsquo;s incident. The discipline is a tag-based lifecycle policy: every temporary resource carries an &lt;code&gt;expires=YYYY-MM-DD&lt;/code&gt; tag at creation, and a scheduled job deletes anything past its expiry. Same principle for ephemeral compute and infra: TTLs at creation, not retroactive sweeps.&lt;/p&gt;
&lt;p&gt;Database tiering is the fourth storage item, and the cost shows up twice: in steady-state storage charges, and again every time someone touches the cluster. On a 30 TiB RDS cluster left alone as &amp;ldquo;the archive&amp;rdquo;, a routine &lt;code&gt;ALTER TABLE&lt;/code&gt; to change one column&amp;rsquo;s datatype kicked off a full table rewrite that ran for a month and cost about $5,000 in IO before completing. The cluster had no active alerts, no recent change requests, and no one watching the bill - the charge accrued the full month before anyone noticed. Hot OLTP storage is the most expensive byte the cloud sells, and tables carrying years of archival rows the application reads less than monthly pay that premium plus a surprise tax on every schema migration. Partitioning by date and moving old partitions to S3, to a slower instance class, or to a column-store warehouse is a one-week project on a known shape. The migration looks unglamorous on a roadmap and gets perpetually deferred until the storage line, or an unexpected five-figure &lt;code&gt;ALTER&lt;/code&gt;, crosses a threshold finance flags.&lt;/p&gt;
&lt;p&gt;The unifying discipline across all four items is preventive: every storage resource needs an explicit retention policy and ownership tags at creation time, enforced in provisioning code rather than by human attention.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;The audit recovers; the wrapped module prevents&lt;/strong&gt;
 &lt;div&gt;A Terraform module that creates an S3 bucket should require &lt;code&gt;lifecycle_rule&lt;/code&gt;, &lt;code&gt;owner&lt;/code&gt;, and &lt;code&gt;expires&lt;/code&gt; (or an explicit &lt;code&gt;retention_class&lt;/code&gt;) as inputs and refuse to plan if they&amp;rsquo;re missing. The same wrapper-module pattern applies to RDS, dev environments, scratch buckets, and one-off compute. Tags applied retroactively only cover what someone remembered to update; tags enforced at the module cover everything provisioned from that point forward, including the infra a future engineer spins up without thinking about cost.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="pin-chatty-pairs-and-right-size-capacity"&gt;Pin chatty pairs and right-size capacity
&lt;/h2&gt;&lt;p&gt;Cross-AZ traffic is the silent compute leak. AWS charges roughly $0.01 per GB for data crossing AZs in both directions; on a chatty service that fans out to a cache and a database in different AZs, the round-trip charges add up to more than the instance cost itself within a few months. The fix is placement. Pin the chatty pair to the same AZ when the consistency model allows it. Batch the calls when it doesn&amp;rsquo;t. Move the cache layer to a per-AZ deployment so each application instance hits its local replica. The audit is one query against VPC flow logs or a glance at the Cost Explorer &amp;ldquo;Data Transfer&amp;rdquo; breakdown filtered by AZ.&lt;/p&gt;
&lt;p&gt;Right-sizing is the next item. Instances provisioned for a load test in 2023 that ran at 12% CPU for two years are still on the bill at the size they were provisioned for. AWS Compute Optimizer and the equivalent recommenders in GCP and Azure are accurate enough to act on for the obvious cases without further investigation. The non-obvious cases (memory-bound workloads, spiky workloads, workloads with seasonal peaks, services with strict latency budgets) need a human pass with a week of metrics in front of them. Either way the data is already in the cloud; nothing has to be instrumented.&lt;/p&gt;
&lt;p&gt;HA is the third. Multi-AZ on a Postgres primary roughly doubles the instance cost. On services where a five-minute outage is genuinely tolerable (internal tools, batch jobs, dev databases, services with a clear retry path on the caller) the second instance is paying for an SLA the business doesn&amp;rsquo;t actually need. The conversation worth having is which services have an RPO and RTO that justifies the standby. Most don&amp;rsquo;t. The original architecture review made the call on every service the same way (HA on, by default) and never revisited it as the service catalog grew.&lt;/p&gt;
&lt;h2 id="tune-queries-on-the-hot-paths"&gt;Tune queries on the hot paths
&lt;/h2&gt;&lt;p&gt;Most items above remove waste in the infra layer. Query tuning and application-side optimization make the existing infra do more work per dollar, and on most systems they&amp;rsquo;re the largest single cost lever in this article. A single N+1 query in a hot path can put 10x more load on the database than necessary, sized as a more expensive RDS, a higher tier in every downstream cache, and more cross-AZ traffic. The infra audit cuts the bill by trimming what isn&amp;rsquo;t needed. Query tuning cuts it by reducing what&amp;rsquo;s actually being done.&lt;/p&gt;
&lt;p&gt;Pick any production codebase older than eighteen months. At least one of the patterns covered elsewhere on this blog is in it, and almost always more than one: &lt;a class="link" href="https://explainanalyze.com/p/orms-are-a-coupling-not-an-abstraction/" &gt;N+1 ORM iteration&lt;/a&gt; on a hot route, &lt;a class="link" href="https://explainanalyze.com/p/non-sargable-predicates-how-a-function-in-where-kills-your-index/" &gt;non-SARGable predicates&lt;/a&gt; that defeat any index, indexes built without &lt;a class="link" href="https://explainanalyze.com/p/uniqueness-and-selectivity-the-two-numbers-that-drive-query-plans/" &gt;understanding selectivity&lt;/a&gt;, &lt;code&gt;OFFSET&lt;/code&gt; pagination past page 50, retry loops without backoff that triple request rate during the exact conditions that caused the original timeout, aggregations recomputed every request that could be cached for thirty seconds, and &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/" &gt;long-held row locks&lt;/a&gt; blocking unrelated work. Each one shows up at the cost layer as more vCPU, more IO, more cache pressure, and more cross-AZ traffic than the workload actually requires.&lt;/p&gt;
&lt;p&gt;Query tuning is more expensive work than the infra audit: reading the slow-query log, profiling hot paths, and refactoring application code that touches the database. The payback shape is better, though. An infra audit recovers a fixed amount once. A query optimization saves on every future request, scaling with traffic growth.&lt;/p&gt;
&lt;h2 id="sweep-sprawl-and-version-surcharges"&gt;Sweep sprawl and version surcharges
&lt;/h2&gt;&lt;p&gt;Lower environments default to running 24/7 at sizes someone picked when production was a quarter of its current size. The cheapest move is scheduled shutdown nights and weekends, where the workload isn&amp;rsquo;t worldwide and the engineers using it aren&amp;rsquo;t online at 3am. A 16-hour weekday shutdown plus full weekends recovers two-thirds of the monthly hours. AWS Instance Scheduler, GCP&amp;rsquo;s recommender, and a 30-line Lambda all do the job. Lower environments don&amp;rsquo;t need HA, don&amp;rsquo;t need the same retention, and don&amp;rsquo;t need the same instance class.&lt;/p&gt;
&lt;p&gt;Per-engineer dev environments and PR-preview deployments are a related leak. Preview environments that spin up on every pull request and don&amp;rsquo;t tear down on close. Forgotten branches with attached infra. Personal sandboxes from engineers who left the company two years ago. Same TTL-at-creation discipline as the temp storage section above.&lt;/p&gt;
&lt;p&gt;The cloud charges a surcharge on deprecated product versions in two places. EC2 instance generations are the visible one. AWS retired a long list of older EC2 generations and quietly raised the per-hour price on the ones still launchable; eventually they refuse to launch at all. Workloads still running on m4, c4, t2, or r3 generations are paying the surcharge today. Migrating to a current generation is usually a stop-start with a different instance type and a brief test window. The audit is &lt;code&gt;aws ec2 describe-instances --query 'Reservations[*].Instances[*].InstanceType'&lt;/code&gt; and a join against the published deprecated-generation list. Same pattern in GCP for retired n1 machines, same in Azure for older v2 series.&lt;/p&gt;
&lt;p&gt;The same surcharge runs on managed databases and the rest of the managed-storage catalog, less visibly. AWS RDS Extended Support charges per-vCPU-per-hour for Postgres, MySQL, and Aurora major versions past community end-of-life, stepping up each year until the version is forcibly upgraded. Postgres 11 hit that surcharge in early 2024; MySQL 5.7 followed. ElastiCache, OpenSearch managed, and DocumentDB have equivalent timelines. Azure SQL and Cloud SQL apply similar fees on out-of-support versions. EBS gp2 volumes carry a quieter version of the dynamic: gp3 is usually cheaper at the same IOPS budget even though gp2 isn&amp;rsquo;t formally deprecated. The audit is &lt;code&gt;aws rds describe-db-instances&lt;/code&gt; joined against the engine&amp;rsquo;s published support timeline. The major upgrade was going to happen eventually; the surcharge puts a deadline on it.&lt;/p&gt;
&lt;p&gt;Unused infra is the easiest sweep and the smallest line item per resource. EBS volumes left detached after the instance was terminated, billed monthly for storage that nothing reads. Elastic IPs not associated with any instance, billed hourly for the privilege of holding them. NAT gateways carrying near-zero traffic at the same hourly base rate as one carrying terabytes. Load balancers with zero healthy targets. RDS snapshots from databases deleted years ago. CloudWatch log groups with no retention policy that have been growing since 2019. The audit script is twenty lines per resource type. The savings are small per item and large in aggregate, and the cleanup is the safest of any item in this article. Nothing in production depends on a detached volume by definition.&lt;/p&gt;
&lt;p&gt;Shared infra is the last item and the hardest call. Centralized logging, metrics, CI runners, internal developer platforms, and shared lower-environment clusters all start as obvious wins because the per-team cost is low and the operational burden is borne by a platform team. Years later the per-team cost has crossed the threshold where running it locally to the team that owns the workload would be cheaper, but the original decision is rarely revisited. The conversation worth having is per-team cost vs. operational complexity, not absolute cost. Centralization wins on operations and loses on per-team economics at scale, and the right answer for a 50-engineer org is rarely the right answer for a 500-engineer one.&lt;/p&gt;
&lt;h2 id="when-this-discipline-isnt-worth-running"&gt;When this discipline isn&amp;rsquo;t worth running
&lt;/h2&gt;&lt;p&gt;Three conditions make the cost sweep overkill. Very small accounts where the total monthly bill is under a few thousand dollars don&amp;rsquo;t repay the engineering time it takes to walk the list. Workloads in a hard regulatory regime where retention, HA, and cross-region replication are externally mandated have less room to cut than the article suggests; the audit still surfaces the line items, but the action set is smaller. And teams in a steep growth phase where the cost of the engineer&amp;rsquo;s time on cost work is more expensive than the savings should defer the sweep until the growth stabilizes. The discipline pays back at sustained scale, in established workloads, with engineering time available to allocate.&lt;/p&gt;
&lt;h2 id="make-it-an-annual-habit"&gt;Make it an annual habit
&lt;/h2&gt;&lt;p&gt;The exercise is re-running old decisions against current numbers, in the order where the gap is biggest. The S3 lifecycle that was reasonable when the bucket held 40,000 objects, the backup retention that was reasonable when the database was 200 GB, the m4 instance that was a fine choice in 2021. None of those decisions were wrong when they were made; the numbers underneath them changed by an order of magnitude and nobody re-ran the math. The audit takes longer the first time because nothing is documented. By year three it&amp;rsquo;s a quarterly quick-pass, and the line item nobody used to read is the one finance forwards as good news.&lt;/p&gt;</description></item><item><title>TEXT and JSON Columns: Where the Schema Goes to Hide</title><link>https://explainanalyze.com/p/text-and-json-columns-where-the-schema-goes-to-hide/</link><pubDate>Thu, 25 Sep 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/text-and-json-columns-where-the-schema-goes-to-hide/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post TEXT and JSON Columns: Where the Schema Goes to Hide" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;A &lt;code&gt;TEXT&lt;/code&gt; or &lt;code&gt;JSON&lt;/code&gt; column moves the schema out of the database catalog and into application code; the data inside has a shape, but the DDL won&amp;rsquo;t tell you what it is. Promote the fields that actually get queried into real columns, and treat the rest as genuinely opaque.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;An AI assistant is asked to &amp;ldquo;find customers who upgraded to enterprise in the last quarter.&amp;rdquo; It reads the catalog, finds &lt;code&gt;api_logs(id, endpoint VARCHAR, payload LONGTEXT, created_at DATETIME)&lt;/code&gt;, and generates the reasonable query:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;JSON_EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;$.action&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;action&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;api_logs&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;JSON_EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;$.action&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;upgrade&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;JSON_EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;$.plan&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;enterprise&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTERVAL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;90&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DAY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Runs clean. Returns zero rows. The actual key was renamed from &lt;code&gt;action&lt;/code&gt; to &lt;code&gt;event.type&lt;/code&gt; two years ago when the team adopted a shared event schema; new rows match &lt;code&gt;$.event.type&lt;/code&gt;, old rows still match &lt;code&gt;$.action&lt;/code&gt;, and no one migrated the historical data because it wasn&amp;rsquo;t queryable anyway. Neither column nor catalog said any of this. The query is syntactically perfect, semantically correct for the key it guessed, and wrong because the key doesn&amp;rsquo;t exist in most of the rows.&lt;/p&gt;
&lt;p&gt;The obvious fix is &amp;ldquo;switch to JSONB, validate with a JSON schema, add a GIN index.&amp;rdquo; Each one helps at the margin and none of them close the gap. JSONB tells you the blob is valid JSON, not what keys are in it. CHECK constraints with &lt;code&gt;JSON_SCHEMA_VALID&lt;/code&gt; or &lt;code&gt;jsonb_matches_schema&lt;/code&gt; work prospectively, but the six years of rows already in the table were written against five format generations and no validator reaches back in time. A GIN index accelerates key lookups but only if you know which keys to look up. The problem isn&amp;rsquo;t the storage format. The schema emigrated to application code, and changing the column type doesn&amp;rsquo;t bring it back.&lt;/p&gt;
&lt;h2 id="what-leaves-the-catalog-when-the-column-becomes-a-blob"&gt;What leaves the catalog when the column becomes a blob
&lt;/h2&gt;&lt;p&gt;DDL is the contract between the database and everything that reads it. A typed column says &amp;ldquo;this value is an integer between 0 and 2³¹−1, and here&amp;rsquo;s the index I&amp;rsquo;ve built over it.&amp;rdquo; A &lt;code&gt;TEXT&lt;/code&gt; or &lt;code&gt;JSON&lt;/code&gt; column says &amp;ldquo;this value is a string the application decided on, and the application can tell you what that means.&amp;rdquo; The second contract is thinner in ways that compound.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Readers can&amp;rsquo;t discover the shape from the schema.&lt;/strong&gt; &lt;code&gt;information_schema.COLUMNS&lt;/code&gt; for a JSON column returns &lt;code&gt;COLUMN_TYPE = 'json'&lt;/code&gt; and nothing else. Every tool that reads catalog metadata (MCP servers, ERD generators, typed-client code generators, AI assistants, new engineers running &lt;code&gt;\d+&lt;/code&gt;) sees a blob. The shape lives in the serializer class, the protobuf definition, the TypeScript interface, or nowhere. Whichever of those the reader happens to find is the shape they&amp;rsquo;ll assume. See &lt;a class="link" href="https://explainanalyze.com/p/comment-your-schema/" &gt;Comment Your Schema&lt;/a&gt; for the lowest-effort way to leave a trail, but comments can describe the shape; they can&amp;rsquo;t make the catalog enforce it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Generational drift is silent.&lt;/strong&gt; Year one the payload is &lt;code&gt;{action, user}&lt;/code&gt;. A migration adds nested metadata: &lt;code&gt;{action, user, metadata: {source}}&lt;/code&gt;. A rewrite flattens and renames: &lt;code&gt;{event: {type, user_id}, source}&lt;/code&gt;. A new service standardizes with a version field: &lt;code&gt;{version: 3, event: {...}}&lt;/code&gt;. All four versions are sitting in the same column with nothing to distinguish them at read time except the keys they happen to have. A JSON_EXTRACT path written against today&amp;rsquo;s producer hits the newest generation and silently misses the older ones. The failure mode is exactly the one described in &lt;a class="link" href="https://explainanalyze.com/p/legacy-schemas-are-sediment-not-design/" &gt;Legacy Schemas Are Sediment&lt;/a&gt;: the schema&amp;rsquo;s history is compressed into the data, and the data can&amp;rsquo;t decompress itself.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Writes are untyped.&lt;/strong&gt; Without CHECK constraints or a JSON-schema validator, the writer is the only guardrail. A service deployed last Tuesday that emits &lt;code&gt;amount&lt;/code&gt; as the string &lt;code&gt;&amp;quot;9900&amp;quot;&lt;/code&gt; instead of the integer &lt;code&gt;9900&lt;/code&gt; silently poisons the column. Downstream queries comparing &lt;code&gt;amount &amp;gt; 1000&lt;/code&gt; work on new rows and misbehave on the poisoned batch, because JSON-extract returns a string and the comparison is lexicographic. The same class of mismatch a typed column would reject on INSERT.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The planner is working blind.&lt;/strong&gt; Row-count estimates on &lt;code&gt;JSON_EXTRACT(payload, '$.event.type') = 'upgrade'&lt;/code&gt; have no histogram to consult; the planner falls back to a default selectivity estimate that&amp;rsquo;s usually wrong. Plans for queries filtered on JSON fields are routinely pessimistic or optimistic by an order of magnitude, and there&amp;rsquo;s no &lt;code&gt;ANALYZE&lt;/code&gt; to fix that because the statistics don&amp;rsquo;t exist for the interior of the blob.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Indexes are per-key, not per-column.&lt;/strong&gt; A functional index on &lt;code&gt;JSON_EXTRACT(payload, '$.event.type')&lt;/code&gt; accelerates one path. The next query filters on &lt;code&gt;$.source&lt;/code&gt; and scans the table. Generated columns are the cleaner version of this (&lt;code&gt;payload_event_type VARCHAR(50) GENERATED ALWAYS AS (JSON_EXTRACT(payload, '$.event.type')) STORED&lt;/code&gt;) but each one is a schema change with a backfill, and you have to know in advance which keys matter. GIN indexes on JSONB cover arbitrary keys but are large, slow to update, and still don&amp;rsquo;t tell the reader what keys exist.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Untyped writes &amp;#43; untyped reads = silent schema drift&lt;/strong&gt;
 &lt;div&gt;A TEXT or JSON column accepts anything the writer emits and returns exactly that on read. Two services writing to the same column with slightly different shapes don&amp;rsquo;t conflict at the database level; they produce a column whose contents depend on which service wrote the row. The divergence is invisible until a query tries to read uniformly across both.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="plausible-paths-empty-results"&gt;Plausible paths, empty results
&lt;/h2&gt;&lt;p&gt;Schema-reading LLMs generate JSON_EXTRACT paths the same way they generate column names in a typed schema, by pattern-matching the column name and the question. Asked about &amp;ldquo;upgrade actions,&amp;rdquo; the model guesses &lt;code&gt;$.action = 'upgrade'&lt;/code&gt; because the English-to-JSON-path mapping is obvious. It has no way to know that the key was renamed, that three generations coexist, or that the canonical name is now buried under two layers of nesting. The catalog gives it a column type of &lt;code&gt;json&lt;/code&gt; and nothing else, and the model&amp;rsquo;s best guess is reasonable and wrong.&lt;/p&gt;
&lt;p&gt;The failure pattern is familiar from other schema-hiding designs. &lt;a class="link" href="https://explainanalyze.com/p/polymorphic-references-are-not-foreign-keys/" &gt;Polymorphic references&lt;/a&gt; hide which table a foreign-key-shaped column points at; &lt;a class="link" href="https://explainanalyze.com/p/the-bare-id-primary-key-when-every-table-joins-to-every-other-table/" &gt;bare &lt;code&gt;id&lt;/code&gt; primary keys&lt;/a&gt; hide which identifier is being compared; TEXT/JSON columns hide what&amp;rsquo;s in the column at all. All three are cases where the LLM generates a plausible query against a schema that isn&amp;rsquo;t telling it enough, and the query returns plausibly-shaped but semantically empty results.&lt;/p&gt;
&lt;h2 id="the-fix-and-where-it-stops-being-free"&gt;The fix, and where it stops being free
&lt;/h2&gt;&lt;p&gt;The lever is being honest about what&amp;rsquo;s inside and picking the right storage per field.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Promote fields that get queried.&lt;/strong&gt; If the application filters on &lt;code&gt;event.type&lt;/code&gt; more than occasionally, that&amp;rsquo;s a real column. Generated columns are the low-friction middle path: derive a typed, indexable column from the JSON, keep the raw payload as the audit trail.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;api_logs&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ADD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COLUMN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;GENERATED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ALWAYS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JSON_UNQUOTE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;JSON_EXTRACT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;$.event.type&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)))&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;STORED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ADD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INDEX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;idx_event_type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;event_type&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The trade-off: every promoted field is a migration, and generated columns don&amp;rsquo;t retroactively rewrite rows written with a different shape; you still need the &lt;code&gt;COALESCE(JSON_EXTRACT(payload, '$.event.type'), JSON_EXTRACT(payload, '$.action'))&lt;/code&gt; cleanup for the old generations, and you&amp;rsquo;re doing that exactly once as part of the promotion rather than in every query.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Enforce new writes with a JSON schema.&lt;/strong&gt; PostgreSQL&amp;rsquo;s &lt;code&gt;pg_jsonschema&lt;/code&gt; and MySQL 8.0&amp;rsquo;s &lt;code&gt;JSON_SCHEMA_VALID&lt;/code&gt; let a CHECK constraint reject writes that don&amp;rsquo;t match a named schema. Doesn&amp;rsquo;t fix existing rows; does stop the next silent format change from landing. If the team doesn&amp;rsquo;t already have a shared event schema, a CHECK constraint is the forcing function that produces one.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Version the payload explicitly.&lt;/strong&gt; &lt;code&gt;{&amp;quot;version&amp;quot;: 3, &amp;quot;payload&amp;quot;: {...}}&lt;/code&gt; at the top lets every reader dispatch on version instead of inferring it from which keys happen to be present. Doesn&amp;rsquo;t help rows written before versioning started, but bounds the drift going forward and turns &amp;ldquo;which generation is this row?&amp;rdquo; from archaeology into a lookup.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Document what stays inside.&lt;/strong&gt; Comments on the column (&amp;ldquo;see github.com/org/events for the schema; versions 1–3 coexist in rows older than 2024-Q2&amp;rdquo;) won&amp;rsquo;t replace types, but they give the reader a place to look. &lt;a class="link" href="https://explainanalyze.com/p/comment-your-schema/" &gt;Comments on the schema&lt;/a&gt; are cheap, in-place, and propagate through every tool that reads the catalog; for genuinely-opaque columns this is the best available signal.&lt;/p&gt;
&lt;h2 id="when-json-is-actually-the-right-answer"&gt;When JSON is actually the right answer
&lt;/h2&gt;&lt;p&gt;The pattern earns its keep in specific shapes where the alternative (typed columns) is worse.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Truly variable shape per row.&lt;/strong&gt; User-supplied settings blobs, custom-field configurations, extension points where the keys are genuinely per-tenant or per-user. Modeling each variant as a column produces a wide table full of NULLs; see &lt;a class="link" href="https://explainanalyze.com/p/god-tables-150-columns-and-the-quiet-cost-of-just-add-a-column/" &gt;God Tables&lt;/a&gt; for the cost of that direction. The column is honest about being schemaless because the data is schemaless.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Audit payloads nobody queries.&lt;/strong&gt; Raw API request/response bodies retained for compliance, debug traces, incident forensics. Written once, read by humans one row at a time, never aggregated. The lack of a queryable schema is fine because no query needs one. A sensible default here is to keep the payload compressed and add a small set of typed columns (&lt;code&gt;endpoint&lt;/code&gt;, &lt;code&gt;status_code&lt;/code&gt;, &lt;code&gt;user_id&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt;) for the predicates the operational queries actually use.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Short-lived staging.&lt;/strong&gt; Job queues, idempotency cache payloads, outbox entries, where the producer and consumer are deployed together, the payload is read once, and the row is deleted on completion. Drift can&amp;rsquo;t accumulate in rows that don&amp;rsquo;t stay around.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Document stores on purpose.&lt;/strong&gt; PostgreSQL JSONB with a stable schema, validated on write, with functional indexes on the paths that matter. This is a real design; it&amp;rsquo;s not the unspoken default that most TEXT columns represent. If the team is reaching for JSONB and treating it as a document store, it should look like one (with validation, indexes, and documentation) not like a TEXT column that happens to parse.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;A TEXT or JSON column is a specific architectural choice: move part of the schema out of the catalog, in exchange for cheaper writes and looser contracts between producer and consumer. When the trade is deliberate (genuinely variable data, write-once audit, short-lived buffer) it&amp;rsquo;s the correct shape. When it&amp;rsquo;s the path of least resistance because typed columns would require a migration, the cost is deferred to every future reader who has to reconstruct the format from commit history.&lt;/p&gt;
&lt;p&gt;Databases are good at enforcing the contracts they know about. The column types are how they know. Every field that matters to a query deserves to be in the part of the schema the database can see; everything else is honestly opaque and should look it. The default drift (&amp;ldquo;stick it in the payload, we&amp;rsquo;ll parse it later&amp;rdquo;) produces columns whose contents nobody fully knows, including the team that wrote them.&lt;/p&gt;</description></item><item><title>Reading the Schema Is Not Reading the Data</title><link>https://explainanalyze.com/p/reading-the-schema-is-not-reading-the-data/</link><pubDate>Mon, 08 Sep 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/reading-the-schema-is-not-reading-the-data/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Reading the Schema Is Not Reading the Data" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;A schema describes the shape the database enforces; the data inside follows a second set of conventions (soft-delete coverage, sentinel values, encoding quirks, format drift) that live nowhere the catalog can show. Queries written from the DDL alone run clean and return results that look right and mean something different. Treat the data as a second source that has to be read, sampled, and documented alongside the types.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;An engineer (or an AI) writes a query to find pending orders:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total_cents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTERVAL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DAY&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;orders.status&lt;/code&gt; is &lt;code&gt;TINYINT NOT NULL&lt;/code&gt;. The query runs. Forty thousand rows come back. Most of them shipped days ago. The mistake lives in the column&amp;rsquo;s other life: &lt;code&gt;status&lt;/code&gt; on this table is a boolean &lt;code&gt;is_processed&lt;/code&gt; flag where &lt;code&gt;1&lt;/code&gt; means &amp;ldquo;has been through the fulfillment pipeline.&amp;rdquo; The order lifecycle state (pending, processing, shipped, delivered, cancelled) is in &lt;code&gt;orders.state&lt;/code&gt;, also &lt;code&gt;TINYINT NOT NULL&lt;/code&gt;, also no comments, and whoever read the schema first picked the column whose name they recognized. The DDL was no help; both columns have the same type, the same nullability, and the same look in &lt;code&gt;information_schema&lt;/code&gt;. The data was telling the real story, and the data wasn&amp;rsquo;t read.&lt;/p&gt;
&lt;p&gt;The obvious fix is &amp;ldquo;add comments, use ENUM, lint for ambiguous names.&amp;rdquo; Each of those helps on new columns and the next migration. None of them touch the existing data, which is where the ambiguity actually lives: forty thousand rows of &lt;code&gt;status = 1&lt;/code&gt; that mean one thing on this table and a different thing on its sibling, ten million VARCHAR dates written by five generations of code in three formats, and a &lt;code&gt;users&lt;/code&gt; table where rows with &lt;code&gt;email = 'DO_NOT_USE@test.com'&lt;/code&gt; have been on the leaderboard for two years. Fixing forward keeps the problem from growing. Reading the data is how you find out what&amp;rsquo;s already there.&lt;/p&gt;
&lt;h2 id="four-ways-the-data-disagrees-with-the-schema"&gt;Four ways the data disagrees with the schema
&lt;/h2&gt;&lt;p&gt;These are not the exotic cases. They show up in nearly every mature production database, and each one is a place where a schema-only read produces a plausible, wrong query.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;TINYINT(1)&lt;/code&gt; is polysemic.&lt;/strong&gt; It stores a boolean flag (&lt;code&gt;is_active&lt;/code&gt;, &lt;code&gt;has_seen_onboarding&lt;/code&gt;, &lt;code&gt;email_verified&lt;/code&gt;), a small enum (lifecycle states, tier levels, priority), a bit-packed byte (eight flags in a single column), or a count that never exceeds 127. All four uses produce identical entries in &lt;code&gt;information_schema&lt;/code&gt;. Naming conventions (&lt;code&gt;is_*&lt;/code&gt;, &lt;code&gt;has_*&lt;/code&gt;, &lt;code&gt;can_*&lt;/code&gt; for booleans; &lt;code&gt;_type&lt;/code&gt;, &lt;code&gt;_status&lt;/code&gt;, &lt;code&gt;_level&lt;/code&gt; for enums) are the informal signal, and like every informal signal, they&amp;rsquo;re applied inconsistently and broken in legacy tables. See &lt;a class="link" href="https://explainanalyze.com/p/schema-conventions-dont-survive-without-automation/" &gt;Schema Conventions and Why They Matter&lt;/a&gt; for the prescriptive side; this is the descriptive reality.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Soft-delete coverage is partial.&lt;/strong&gt; Some tables have &lt;code&gt;deleted_at TIMESTAMP NULL&lt;/code&gt;. Some have &lt;code&gt;is_deleted TINYINT(1) DEFAULT 0&lt;/code&gt;. Most have neither, because the original author decided the table didn&amp;rsquo;t need soft deletes and nobody revisited. A query that correctly filters &lt;code&gt;WHERE deleted_at IS NULL&lt;/code&gt; on &lt;code&gt;customers&lt;/code&gt; returns the right answer; the same pattern applied to &lt;code&gt;addresses&lt;/code&gt; either errors out (column doesn&amp;rsquo;t exist) or silently matches everything (column exists but is always NULL because the application never writes to it). There&amp;rsquo;s no global rule to encode and no way to know from the catalog which tables fall in which bucket. You have to read the data, or read the application code that writes to it (which is usually worse).&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;VARCHAR dates in multiple formats.&lt;/strong&gt; A column called &lt;code&gt;signup_date VARCHAR(10)&lt;/code&gt; is a tell. The first generation of rows has &lt;code&gt;YYYY-MM-DD&lt;/code&gt;. A rewrite that switched import vendors introduced &lt;code&gt;MM/DD/YYYY&lt;/code&gt;. An international expansion produced &lt;code&gt;DD/MM/YYYY&lt;/code&gt; for rows that came in through a specific endpoint and &lt;code&gt;DD-Mon-YYYY&lt;/code&gt; for one partner&amp;rsquo;s CSV imports. All four formats live in the same column. &lt;code&gt;WHERE signup_date &amp;gt;= '2025-01-01'&lt;/code&gt; matches the first generation correctly, matches the third generation backwards (&amp;ldquo;2025-01-01&amp;rdquo; sorts before &amp;ldquo;15/03/2024&amp;rdquo;), and misses the fourth entirely because the sort order doesn&amp;rsquo;t touch &lt;code&gt;Mon&lt;/code&gt; strings. The query returned rows, so the reviewer moved on.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Sentinel values and test data.&lt;/strong&gt; Row with &lt;code&gt;user_id = 0&lt;/code&gt; means &amp;ldquo;anonymous.&amp;rdquo; Row with &lt;code&gt;email = 'DO_NOT_USE@test.com'&lt;/code&gt; is a test account that&amp;rsquo;s been in production for three years because nobody wanted to take responsibility for deleting it. Row with &lt;code&gt;created_at = '1970-01-01 00:00:00'&lt;/code&gt; is a backfill where the original timestamp was unknown and epoch zero got written as a placeholder. Every one of these is an intentional violation of the apparent meaning of the column, and every schema-level read treats them as ordinary data. Copilot ranked &lt;code&gt;DO_NOT_USE&lt;/code&gt; as the top customer with $99,999 in revenue because the row had the highest total; the test record had been sitting there for years, visible to anyone who queried the table but invisible to anyone who only read the DDL.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Input-convention drift.&lt;/strong&gt; &lt;code&gt;VARCHAR(255)&lt;/code&gt; accepts &amp;ldquo;Acme Corp,&amp;rdquo; &amp;ldquo;ACME CORPORATION,&amp;rdquo; &amp;ldquo;Acme Corp.,&amp;rdquo; &amp;ldquo;acme corp,&amp;rdquo; and &amp;ldquo;ACME CORP&amp;rdquo; (two spaces, somebody&amp;rsquo;s trailing whitespace bug). All five are the same company in different rows. The unique constraint, if it exists, didn&amp;rsquo;t catch any of them because they&amp;rsquo;re not byte-identical. Any query that groups or joins on the text field silently double-counts - not by a small amount, by however much the convention drift is worth. Encoding quirks compound: &lt;code&gt;café&lt;/code&gt; in NFC and NFD look identical in the terminal and hash differently; case-folding depends on collation; trailing whitespace varies by source system.&lt;/p&gt;
&lt;h2 id="why-the-catalog-cant-tell-you-this"&gt;Why the catalog can&amp;rsquo;t tell you this
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;information_schema&lt;/code&gt; describes the contract the database enforces on writes. That contract is narrow: types, nullability, defaults, constraints, foreign keys. It doesn&amp;rsquo;t describe what got written before the constraint was added (almost all of it), what gets written by code paths that bypass the ORM (a surprising fraction of it), or what the application decided to write into a column that the database happily accepts because the type matches.&lt;/p&gt;
&lt;p&gt;Type compatibility is a floor, not a ceiling. &lt;code&gt;TINYINT NOT NULL&lt;/code&gt; excludes strings, NULLs, and integers outside &lt;code&gt;[-128, 127]&lt;/code&gt;. It doesn&amp;rsquo;t exclude &lt;code&gt;1&lt;/code&gt; meaning five different things in five different tables, because that&amp;rsquo;s not a type constraint - it&amp;rsquo;s a semantic one, and the database has no vocabulary for semantics. The same logic applies to &lt;a class="link" href="https://explainanalyze.com/p/null-in-sql-three-valued-logic-and-the-silent-bug-factory/" &gt;NULL handling&lt;/a&gt;: the catalog tells you a column is nullable; it doesn&amp;rsquo;t tell you whether NULL means &amp;ldquo;unset,&amp;rdquo; &amp;ldquo;not applicable,&amp;rdquo; &amp;ldquo;still in progress,&amp;rdquo; or &amp;ldquo;data lost during the 2019 migration.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;LLMs inherit this limitation directly. A model generating SQL from the catalog sees column names and types, not data distributions. It has no way to tell that &lt;code&gt;status&lt;/code&gt; is polysemic across tables, that &lt;code&gt;deleted_at&lt;/code&gt; exists on four of the six relevant tables, or that &lt;code&gt;signup_date&lt;/code&gt; has three format generations. The LLM&amp;rsquo;s best guess is the one a new engineer would make: the schema looks uniform, so the data probably is. Neither is wrong in general; both are wrong often enough in mature databases to produce plausibly-shaped and semantically-hollow query results. This is the generalization of the specific patterns covered in &lt;a class="link" href="https://explainanalyze.com/p/legacy-schemas-are-sediment-not-design/" &gt;Legacy Schemas Are Sediment&lt;/a&gt;; legacy schemas are one source of data drift, and there are others.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Runs clean, returns plausible, means something else&lt;/strong&gt;
 &lt;div&gt;Schema-only queries fail in the quietest way a query can fail. The SQL is syntactically correct. The types match. Rows come back. Some fraction of those rows mean what the author intended, and some fraction mean something else, and there&amp;rsquo;s no signal at the database level telling you which is which. Reviewers who only look at the query text can&amp;rsquo;t catch it. The data is where the check has to happen.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="the-fix-is-a-habit-not-a-migration"&gt;The fix is a habit, not a migration
&lt;/h2&gt;&lt;p&gt;You can&amp;rsquo;t retroactively enforce a schema on ten years of writes. You can change what the next reader (human or model) has available before they generate the next query.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Profile before you query.&lt;/strong&gt; Before writing a predicate against an unfamiliar column, run a one-liner: &lt;code&gt;SELECT col, COUNT(*) FROM t GROUP BY col ORDER BY COUNT(*) DESC LIMIT 20&lt;/code&gt;. For low-cardinality columns (status, type, flags) this reveals the actual value distribution in thirty seconds and catches the flag-versus-enum mistake before the query ships. For higher-cardinality columns, sample: &lt;code&gt;SELECT col FROM t ORDER BY RAND() LIMIT 50&lt;/code&gt;. The time cost is minutes; the catch rate is substantial.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Comment the columns the DDL can&amp;rsquo;t describe.&lt;/strong&gt; A one-line comment on &lt;code&gt;orders.status&lt;/code&gt; (&lt;code&gt;'Pending=1, Processing=2, Shipped=3, Delivered=4, Cancelled=5'&lt;/code&gt;) and on &lt;code&gt;orders.state&lt;/code&gt; (&lt;code&gt;'Boolean: 1 if order has been through fulfillment'&lt;/code&gt;) is the difference between a reader who gets it right and one who guesses. &lt;a class="link" href="https://explainanalyze.com/p/comment-your-schema/" &gt;Comment Your Schema&lt;/a&gt; covers the mechanics in full; for the flag/enum disambiguation specifically, this is the highest-leverage fix per character of effort anywhere in schema maintenance.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CHECK constraints for new values.&lt;/strong&gt; &lt;code&gt;CHECK (status IN (1,2,3,4,5))&lt;/code&gt; is the forcing function for the next writer. It won&amp;rsquo;t clean up existing rows, and it won&amp;rsquo;t stop a future engineer from reaching for &lt;code&gt;6&lt;/code&gt;, but it will fail loudly when they try, instead of silently accepting a value the readers of the table don&amp;rsquo;t know about. On nullable columns, &lt;code&gt;CHECK (deleted_at IS NULL OR deleted_at &amp;gt; created_at)&lt;/code&gt; catches the backfill-sentinel case.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Migrate VARCHAR dates when you can afford it.&lt;/strong&gt; The migration is real work: parse each row, fail loudly on unparseable formats, pick a canonical representation, backfill. Leaving VARCHAR in place guarantees the next query is written against whichever format the author happened to sample. The right-sized fix in the meantime: a comment on the column listing the known formats, and a view that exposes a parsed &lt;code&gt;DATE&lt;/code&gt; for the queries that can tolerate loss on the unparseable rows.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Treat data profiling as part of review.&lt;/strong&gt; When a PR adds a new query, the reviewer&amp;rsquo;s first question is &amp;ldquo;does this predicate match the data?&amp;rdquo;, which requires actually looking at the data, not just the query. For AI-assisted development this is even more load-bearing: the model generated the query from the catalog, so the human review is the only layer that can compare the query&amp;rsquo;s predicates to the column&amp;rsquo;s actual contents.&lt;/p&gt;
&lt;h2 id="when-schema-only-reading-is-fine"&gt;When schema-only reading is fine
&lt;/h2&gt;&lt;p&gt;Not every database carries this baggage. Three cases where the schema really is the data&amp;rsquo;s description:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Schemas designed from scratch with strict conventions.&lt;/strong&gt; New services, greenfield tables, codebases where every column has a comment, every enum is an ENUM type, and every date column is &lt;code&gt;DATE&lt;/code&gt; or &lt;code&gt;TIMESTAMPTZ&lt;/code&gt;. The drift hasn&amp;rsquo;t had time to accumulate, and the conventions are enforced by linters on migrations. The failure modes described above can still show up; they show up as bugs that get caught, not as the steady-state of the table.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Small, single-team databases.&lt;/strong&gt; Twenty tables, three engineers, all the data flowing through one service. Everyone who writes to the table knows what the conventions are; the data drift is small because there are only three writers. The cost of the habit described above exceeds the cost of the drift it catches. Grow the team or the table count by a factor of ten and the math flips.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Analytical warehouses that expect exploration.&lt;/strong&gt; In a BigQuery, Snowflake, or ClickHouse dataset built for analytics, everyone who queries the data profiles it as a matter of course: sample the column, check the distribution, look for nulls. The profiling habit is already the workflow; the schema is treated as a hint rather than a contract. This is the part of the data stack where reading the data is assumed, and the failure mode is correspondingly rare.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;A production database has two artifacts worth reading: the DDL the engine enforces, and the data the engine happens to hold. The first is legible, indexed, and comes with tooling; the second is tribal knowledge, distributed across rows written by years of code, and invisible to every tool that stops at the catalog. Everyone from new engineers to LLMs reads the first artifact and assumes it describes the second, which is true in schemas fresh enough to have no drift and false in every schema old enough to have generated any.&lt;/p&gt;
&lt;p&gt;Rigor on new tables pays off, but the larger lever is routine comparison between what the schema says and what the data does: sampling before querying, commenting columns whose meaning isn&amp;rsquo;t self-evident, treating data profiling as part of review rather than a debugging step. None of it scales to &amp;ldquo;we documented the whole schema in one sprint.&amp;rdquo; It scales one column at a time, on the columns that are about to be queried, until the fraction of the schema that lies to its readers is small enough to stop costing incidents.&lt;/p&gt;</description></item><item><title>Random UUIDs as Primary Keys: The B-Tree Penalty</title><link>https://explainanalyze.com/p/random-uuids-as-primary-keys-the-b-tree-penalty/</link><pubDate>Fri, 22 Aug 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/random-uuids-as-primary-keys-the-b-tree-penalty/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Random UUIDs as Primary Keys: The B-Tree Penalty" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;UUIDv4 primary keys are globally unique and coordination-free, and the cost is paid every time you write a row: random B-tree positions, page splits, secondary indexes bloated with 16- or 36-byte key copies, and a working set that stops fitting in the buffer pool once the table is large enough. UUIDv7 fixes the insert-locality problem (time-ordered, sortable) without changing storage size; the full fix is picking v7, storing as &lt;code&gt;BINARY(16)&lt;/code&gt; or native &lt;code&gt;uuid&lt;/code&gt;, and keeping UUIDs at the API boundary rather than internal to every join.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;A table configured like this on day one looks unremarkable:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;CHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;36&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- UUIDv4, generated by the application
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Inserts are fast, reads are fast, the ORM is happy. At 100,000 rows, it&amp;rsquo;s still fine. At 10 million, the nightly ingest job gets noticeably slower. At 200 million, inserts take 50 ms each instead of 2 ms, the buffer pool is constantly churning, and the secondary indexes are three to four times the size they&amp;rsquo;d be with a &lt;code&gt;BIGINT&lt;/code&gt; primary key. Nothing about the schema changed. The table just got large enough for a design decision to start charging rent.&lt;/p&gt;
&lt;p&gt;The obvious fix is &amp;ldquo;use BIGINT auto-increment.&amp;rdquo; That&amp;rsquo;s the right answer in a lot of cases and the wrong one in others; it reintroduces coordination requirements, leaks row counts through URL-exposed IDs, and doesn&amp;rsquo;t work for schemas that need to be generated offline or across shards. UUIDs exist because those constraints are real. The sharper question is: what exactly is UUIDv4 costing you at scale, and which of those costs have cheaper alternatives?&lt;/p&gt;
&lt;h2 id="what-random-keys-do-to-a-b-tree"&gt;What random keys do to a B-tree
&lt;/h2&gt;&lt;p&gt;B-tree indexes are sorted structures. When the primary key is an auto-incrementing integer, every new row goes to the end. The rightmost leaf page is the only one that gets written to, and the rest of the index stays in cache undisturbed. Inserts are sequential and cheap.&lt;/p&gt;
&lt;p&gt;UUIDv4 is random by design. Every new row lands at a random position in the B-tree. Instead of appending to one page, the engine has to:&lt;/p&gt;
&lt;ol&gt;
&lt;li&gt;Find the right page somewhere in the middle of the tree.&lt;/li&gt;
&lt;li&gt;Load it into the buffer pool if it isn&amp;rsquo;t already (on a large table, it usually isn&amp;rsquo;t).&lt;/li&gt;
&lt;li&gt;Split it if it&amp;rsquo;s full.&lt;/li&gt;
&lt;li&gt;Write both halves back.&lt;/li&gt;
&lt;/ol&gt;
&lt;p&gt;On a table with hundreds of millions of rows, the index doesn&amp;rsquo;t fit in memory, so most inserts trigger a random disk read before they can do anything else. The write amplification is real and measurable: factor of 5 to 10× versus sequential inserts isn&amp;rsquo;t unusual.&lt;/p&gt;
&lt;p&gt;The damage doesn&amp;rsquo;t stop at the primary-key index. In InnoDB (MySQL), every secondary index includes a copy of the primary key at its leaves. A 36-byte &lt;code&gt;CHAR(36)&lt;/code&gt; UUID embedded in every secondary index entry means larger indexes, more pages, more I/O compared to an 8-byte &lt;code&gt;BIGINT&lt;/code&gt;. Secondary indexes on a UUID-keyed table are routinely 3–4× the size of the same indexes on a &lt;code&gt;BIGINT&lt;/code&gt;-keyed table. Every lookup through a secondary index reads more pages to cover the same rows.&lt;/p&gt;
&lt;p&gt;PostgreSQL handles storage differently. Its heap means the primary key is just another index, so the physical table isn&amp;rsquo;t ordered by it. The primary-key index still suffers the same random-insertion pathology, and the write amplification from random page loads still applies.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Page splits compound over time.&lt;/strong&gt; When a new UUID lands in a full page, InnoDB splits the page in two, each roughly half full. Over millions of inserts, the index develops internal fragmentation: pages allocated but only partially used. The index is physically larger than it needs to be, and scans read more pages for the same row count. &lt;code&gt;OPTIMIZE TABLE&lt;/code&gt; (MySQL) or &lt;code&gt;REINDEX&lt;/code&gt; (PostgreSQL) can repack the index, but on a busy table it&amp;rsquo;s a maintenance window you have to schedule.&lt;/p&gt;
&lt;h2 id="uuidv7-the-insert-locality-fix"&gt;UUIDv7: the insert-locality fix
&lt;/h2&gt;&lt;p&gt;UUIDv7 is the version most new code should reach for when UUIDs are the right answer. It encodes a Unix millisecond timestamp into the high 48 bits, with random bits filling the rest. Two practical consequences:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Sortable.&lt;/strong&gt; Sequential generation means new IDs land at the end of the B-tree, not scattered across it. Insert locality is close to a &lt;code&gt;BIGINT&lt;/code&gt;&amp;rsquo;s. The pathological page-split behaviour of v4 goes away.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Time-parseable.&lt;/strong&gt; The creation time is embedded in the ID, recoverable from the primary key alone: useful for log correlation, rough time-range filtering, and debugging without reaching for &lt;code&gt;created_at&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- UUIDv7: time-ordered, so inserts are roughly sequential
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- PostgreSQL 18 ships a built-in uuidv7() function
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;UUID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DEFAULT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;uuidv7&lt;/span&gt;&lt;span class="p"&gt;(),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Recover creation time from the ID - no created_at column needed
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;uuid_extract_timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;uuid_extract_timestamp&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;date&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_date&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- v7 sorts chronologically, newest first
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LIMIT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;uuid_extract_timestamp()&lt;/code&gt; has existed in PostgreSQL since 17 but only returned a value for UUIDv1. PG 18 extended it to support v7 alongside the new &lt;code&gt;uuidv7()&lt;/code&gt; generator. One caveat: calling it in a &lt;code&gt;WHERE&lt;/code&gt; clause (&lt;code&gt;WHERE uuid_extract_timestamp(id) &amp;gt;= '2026-04-01'&lt;/code&gt;) is non-SARGable and forces a scan; see &lt;a class="link" href="https://explainanalyze.com/p/non-sargable-predicates-how-a-function-in-where-kills-your-index/" &gt;Non-SARGable Predicates&lt;/a&gt;. For indexed time-range filtering, keep a &lt;code&gt;created_at&lt;/code&gt; column as the query target, or compare against a boundary UUID generated at the target timestamp.&lt;/p&gt;
&lt;p&gt;MySQL 8 doesn&amp;rsquo;t ship a v7 generator or a timestamp extractor, so application-side generation is the norm there - libraries exist in every major language, and most modern ORMs default to v7 if you ask for UUIDs. Extraction is manual: for &lt;code&gt;BINARY(16)&lt;/code&gt; storage (the recommended form), the first 6 bytes hold the millisecond timestamp.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- MySQL: manually parse v7&amp;#39;s timestamp prefix (BINARY(16) storage)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;FROM_UNIXTIME&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CONV&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HEX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUBSTRING&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;FROM_UNIXTIME&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;CONV&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;HEX&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SUBSTRING&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;/&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_date&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- v7 sorts chronologically
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LIMIT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;For &lt;code&gt;CHAR(36)&lt;/code&gt; storage, the extraction strips hyphens first: &lt;code&gt;CONCAT(SUBSTRING(id, 1, 8), SUBSTRING(id, 10, 4))&lt;/code&gt; gives the 12 hex characters of the timestamp prefix. If your v1 UUIDs were stored with &lt;code&gt;UUID_TO_BIN(id, 1)&lt;/code&gt; (the swap flag that reorders bytes for v1 index locality), the byte layout differs and the substring offsets change. Most v7-generating libraries skip the swap because v7 is already time-ordered without it - check what yours does before trusting the extraction.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;What v7 doesn&amp;rsquo;t change.&lt;/strong&gt; It&amp;rsquo;s still 16 bytes on disk, and still 36 if you stored it as &lt;code&gt;CHAR(36)&lt;/code&gt;. The insert-locality win doesn&amp;rsquo;t come with a storage discount, so the overhead versus a &lt;code&gt;BIGINT&lt;/code&gt; is the same as v4. The readable creation timestamp is usually a feature and occasionally a problem: in systems where row-creation time is sensitive (order IDs revealing traffic patterns to competitors, user IDs exposing signup timing), it&amp;rsquo;s the one property v4 had that v7 gives up.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;CHAR(36) is the silent tax&lt;/strong&gt;
 &lt;div&gt;The worst-case UUID storage, &lt;code&gt;CHAR(36)&lt;/code&gt;, is what most ORM-generated schemas default to, because it&amp;rsquo;s the portable representation. &lt;code&gt;BINARY(16)&lt;/code&gt; in MySQL or the native &lt;code&gt;uuid&lt;/code&gt; type in PostgreSQL cuts storage by more than half and keeps comparisons on fixed-width integers instead of strings. Pick the narrow form on day one; retrofitting it later is a full-table rewrite that touches every secondary index.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="uuid-to-integer-mapping-keep-uuids-at-the-edge"&gt;UUID-to-integer mapping: keep UUIDs at the edge
&lt;/h2&gt;&lt;p&gt;The other workable fix is structural: expose UUIDs externally, use integers internally. A single lookup table maps the external UUID to an internal &lt;code&gt;BIGINT&lt;/code&gt;, and every other table in the database uses the &lt;code&gt;BIGINT&lt;/code&gt; as its foreign key. The UUID lookup happens once (at the API boundary) and everything downstream is fast, compact, 8-byte integer joins.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;external_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BINARY&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- the UUID the outside world sees
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Every other table references the BIGINT, not the UUID
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- API request comes in with a UUID; one indexed lookup to resolve it
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;external_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;UUID_TO_BIN&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;a1b2c3d4-...&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- From here on, everything uses the BIGINT
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The UUID column has a unique index, so the lookup is a single index seek, sub-millisecond regardless of table size. The rest of the schema gets 8-byte keys everywhere: smaller indexes, faster joins, no page splits, no secondary-index bloat. The external-facing API still uses UUIDs, so you don&amp;rsquo;t leak sequence information or row counts.&lt;/p&gt;
&lt;p&gt;The trade-off is an extra layer of indirection. Every inbound request resolves the UUID before anything else; in practice this is negligible (one indexed lookup), but it means the schema has two identity systems to maintain. For long-lived OLTP applications where every join on every table pays the UUID cost, this structure is often worth the extra lookup.&lt;/p&gt;
&lt;h2 id="when-random-uuids-are-actually-fine"&gt;When random UUIDs are actually fine
&lt;/h2&gt;&lt;p&gt;Not every schema needs to bend. Three cases where UUIDv4 as a primary key is a defensible choice:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Small tables that stay small.&lt;/strong&gt; A configuration table, a lookup table, a feature-flag table. At 50,000 rows the page-split pathology doesn&amp;rsquo;t show up, secondary indexes are tiny, and the convenience of client-generated IDs outweighs any cost.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Write rates low enough that random I/O doesn&amp;rsquo;t matter.&lt;/strong&gt; An admin tool recording 50 events per minute doesn&amp;rsquo;t care about write amplification. The index fits in cache, every page is warm, page splits happen rarely enough that fragmentation stays manageable. &amp;ldquo;Doesn&amp;rsquo;t survive scale&amp;rdquo; is only a problem at scale.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Information-leak concerns that outweigh performance.&lt;/strong&gt; If hiding creation-order is a hard requirement (competitive, privacy, or security), v7&amp;rsquo;s embedded timestamp is a non-starter and v4 is the only UUID version that meets the requirement. Pay the write-amplification cost and use the UUID-to-integer mapping to contain the damage.&lt;/p&gt;
&lt;h2 id="why-v4-keeps-showing-up-as-the-default"&gt;Why v4 keeps showing up as the default
&lt;/h2&gt;&lt;p&gt;Schema-reading assistants and scaffolding tools reinforce UUIDv4 as the default answer, and the reason is mostly inertial. Training corpora are heavy on examples where &lt;code&gt;uuid4()&lt;/code&gt; is the canonical &amp;ldquo;globally unique ID&amp;rdquo; call; &lt;code&gt;CREATE TABLE ... id UUID DEFAULT gen_random_uuid()&lt;/code&gt; appears in orders of magnitude more tutorials than the v7 equivalent. Asked for a new table schema, a model produces the v4 version because that&amp;rsquo;s what the surrounding code it learned from produced. B-tree locality and write amplification don&amp;rsquo;t show up in the DDL (they&amp;rsquo;re runtime properties of the key distribution) so the catalog gives no signal that v4 and v7 behave differently at 100M rows. Both look identical in &lt;code&gt;information_schema&lt;/code&gt;: &lt;code&gt;uuid&lt;/code&gt; or &lt;code&gt;char(36)&lt;/code&gt;, primary key, not null.&lt;/p&gt;
&lt;p&gt;The fix is the same discipline this post already makes the case for, with one amplified beat: document the choice where a schema reader can find it. A comment on the PK column (&lt;code&gt;'UUIDv7: time-ordered; required for insert locality'&lt;/code&gt; or &lt;code&gt;'UUIDv4: randomized; chosen to hide creation order'&lt;/code&gt;) turns a silent convention into a machine-readable decision. The next reader (teammate or model) sees why the column is what it is and why the alternative was rejected. Without the comment, the next table scaffolded by an assistant inherits whichever version the training data sampled, and the schema drifts toward whichever default produces the most plausible-looking DDL.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;UUIDv4 is a tool that solved a coordination problem (distributed ID generation without central authority) and accidentally became the default for everything, including the cases where coordination wasn&amp;rsquo;t a problem and the cost of random writes is non-trivial. &amp;ldquo;Pick a UUID for your PK&amp;rdquo; is a decision most schemas make without ever being explicit about what they&amp;rsquo;re trading.&lt;/p&gt;
&lt;p&gt;The decision matrix is short. Do you need globally unique, coordination-free IDs? If no, use &lt;code&gt;BIGINT&lt;/code&gt;. If yes, use UUIDv7 and store it as &lt;code&gt;BINARY(16)&lt;/code&gt; or native &lt;code&gt;uuid&lt;/code&gt;, never &lt;code&gt;CHAR(36)&lt;/code&gt;. If v7&amp;rsquo;s embedded timestamp is a problem, use v4 but keep it at the API boundary and use integers inside the schema. Each of those decisions costs almost nothing on day one and saves a lot of rework at 100 million rows.&lt;/p&gt;</description></item><item><title>God Tables: 150 Columns and the Quiet Cost of 'Just Add a Column'</title><link>https://explainanalyze.com/p/god-tables-150-columns-and-the-quiet-cost-of-just-add-a-column/</link><pubDate>Tue, 05 Aug 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/god-tables-150-columns-and-the-quiet-cost-of-just-add-a-column/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post God Tables: 150 Columns and the Quiet Cost of 'Just Add a Column'" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;A wide table looks cheap because every column was added for a real reason; the expensive part is that rows grow, every write amplifies, and every secondary index inherits the bloat. The fix is splitting by access pattern (columns read together stay together, rarely-touched columns move out), not aggressive normalization that trades one wide table for six-way joins on every read.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The schema started clean four years ago: &lt;code&gt;users(id, email, password_hash, created_at)&lt;/code&gt;, four columns. Today the table is renamed &lt;code&gt;customers&lt;/code&gt; and has 184 columns. Billing address. Shipping address. Three additional shipping addresses numbered 2 through 4. &lt;code&gt;preferences_json&lt;/code&gt; for user settings. Twelve feature-flag &lt;code&gt;TINYINT&lt;/code&gt;s. Three Stripe identifiers from three processor migrations. &lt;code&gt;last_login_at&lt;/code&gt;, &lt;code&gt;last_seen_at&lt;/code&gt;, &lt;code&gt;last_purchase_at&lt;/code&gt;, &lt;code&gt;last_notification_sent_at&lt;/code&gt;. Forty more columns whose meaning lives in Confluence, if anywhere. No single &lt;code&gt;ALTER TABLE ADD COLUMN&lt;/code&gt; was unreasonable at the time. The accumulated result is an average row size of 6KB, an UPDATE to &lt;code&gt;last_login_at&lt;/code&gt; that rewrites every byte of it, and a buffer pool holding four customer rows per page instead of forty.&lt;/p&gt;
&lt;p&gt;The obvious fix is to normalize it: split into &lt;code&gt;customer_profile&lt;/code&gt;, &lt;code&gt;customer_billing&lt;/code&gt;, &lt;code&gt;customer_addresses&lt;/code&gt;, &lt;code&gt;customer_preferences&lt;/code&gt;, &lt;code&gt;customer_feature_flags&lt;/code&gt;, &lt;code&gt;customer_audit&lt;/code&gt;. That&amp;rsquo;s the textbook answer and it&amp;rsquo;s the one that breaks the moment you look at the dominant read. The list view on the admin page needs name, email, status, last login, Stripe status, and total spent. Now it&amp;rsquo;s a six-way join on every page load. The fix that looked clean in the migration doc makes the most-frequent query more expensive, not less. The read cost moves to the place it&amp;rsquo;s paid most often, and somebody (usually a few months later) proposes a materialized view to &amp;ldquo;just flatten it back out,&amp;rdquo; which is the god table returning through a different door.&lt;/p&gt;
&lt;h2 id="how-a-row-store-actually-reads-a-row"&gt;How a row-store actually reads a row
&lt;/h2&gt;&lt;p&gt;Before the cost math makes sense: OLTP engines like InnoDB and PostgreSQL&amp;rsquo;s heap store complete rows laid out contiguously on fixed-size pages - typically 16KB in InnoDB, 8KB in PostgreSQL. A page holds as many rows as fit. When a query needs one column of one row, the engine doesn&amp;rsquo;t read that column alone; it locates the row&amp;rsquo;s page via an index lookup or scan, loads the whole page into the buffer pool, and reads the requested column out of the in-memory row image.&lt;/p&gt;
&lt;p&gt;The one exception is the index-only scan: if every column the query projects and filters on is already present inside an index, the base table doesn&amp;rsquo;t have to be touched and only the index pages are loaded. See &lt;a class="link" href="https://explainanalyze.com/p/covering-index-traps-when-adding-one-column-breaks-your-query/" &gt;Covering Index Traps&lt;/a&gt; for how quickly this optimization disappears, usually the moment a SELECT list grows by one column. Every other read path goes through the row, which means the row&amp;rsquo;s width sets the floor on how much data the engine moves per lookup. Reading &lt;code&gt;email&lt;/code&gt; from a 184-column customer row loads 6KB into memory to return 50 bytes; reading the same column from an 800-byte row loads 800 bytes. The buffer pool is a fixed size and every byte of unused column data in it is displacing something another query needs.&lt;/p&gt;
&lt;p&gt;Column stores (ClickHouse, BigQuery, Parquet-backed warehouses) invert this entirely. Data is laid out by column, so reading one column reads only that column&amp;rsquo;s storage. The wide-table cost math doesn&amp;rsquo;t apply there, which is why this anti-pattern is specifically a row-store OLTP problem and why denormalized fact tables in analytical warehouses are fine at 300 columns.&lt;/p&gt;
&lt;h2 id="what-150-columns-actually-costs"&gt;What 150 columns actually costs
&lt;/h2&gt;&lt;p&gt;The individual cost of one column is negligible. The system-level cost shows up in several places at once, and none of them are visible in a diff that adds one more.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Row size and write amplification.&lt;/strong&gt; InnoDB stores full rows on disk pages, and an UPDATE rewrites the entire row even if only one column changed. On a 184-column table averaging 6KB per row, updating &lt;code&gt;last_login_at&lt;/code&gt; on every sign-in rewrites 6KB, not 8 bytes. PostgreSQL doesn&amp;rsquo;t rewrite in place (MVCC creates a new tuple for every UPDATE and marks the old one dead) but the new tuple is 6KB too, and &lt;code&gt;VACUUM&lt;/code&gt; has that much more to reclaim. Either engine, the write cost per logical change scales with row width.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Buffer pool density.&lt;/strong&gt; The page-per-read mechanism above means buffer-pool efficiency scales inversely with row width. At 6KB per row, an InnoDB 16KB page holds two rows; at 400 bytes per row it holds forty. A database with 10GB of buffer pool has the effective working set of a much smaller instance once rows get wide. Queries that used to run hot start touching disk for no reason other than that the rows they cared about no longer fit in memory alongside the rows other queries cared about.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Secondary indexes inherit the width problem.&lt;/strong&gt; Every secondary index in InnoDB carries a copy of the primary key at its leaves; every index entry is a key-columns + PK-copy record. A wide table tends to accumulate indexes: you index email, Stripe ID, last-login, phone, region, account-manager-ID, each for a different query path. Six secondary indexes on a 184-column table isn&amp;rsquo;t unusual, and each of them is physically larger than it would be on a narrow table, because the PK copy and fill-factor choices interact with row density. &lt;a class="link" href="https://explainanalyze.com/p/covering-index-traps-when-adding-one-column-breaks-your-query/" &gt;Covering indexes&lt;/a&gt; are also harder to arrange: the list view wants eight columns projected, and indexing eight columns of a 184-column table to cover one query is an expensive trade.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Lock and transaction width.&lt;/strong&gt; Every UPDATE acquires a row-level lock. Transactions that touch a wide row hold that lock for the duration of the transaction, and because the row spans many concerns (billing, preferences, audit timestamps) transactions from unrelated code paths contend on the same row. A background job updating &lt;code&gt;last_seen_at&lt;/code&gt; now serializes against a billing job updating &lt;code&gt;stripe_customer_id&lt;/code&gt; on the same customer, because both paths lock the same row. In the split-by-concern shape, they&amp;rsquo;d contend on different rows of different tables.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Schema migrations get more expensive.&lt;/strong&gt; &lt;code&gt;ALTER TABLE ADD COLUMN&lt;/code&gt; on a 184-column table is slower, holds metadata locks longer, and has a larger blast radius if it fails. MySQL&amp;rsquo;s online DDL is usually fine for NULL-default additions; PostgreSQL is generally fast for the same case. Any migration that needs to rewrite rows (changing a column type, adding NOT NULL with a backfill) scales with row size, and a 6KB row rewrite on 200 million rows is a different operation than an 800-byte row rewrite on the same count.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Every column is a commitment&lt;/strong&gt;
 &lt;div&gt;The cost of adding a column is small and immediate. The cost of having 150 columns is systemic and deferred: buffer-pool density, index size, write amplification, lock contention, migration cost. None of the deferred costs are visible in the PR that adds one more column, which is why they accumulate uncorrected until the table is painful.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="why-llms-make-this-worse"&gt;Why LLMs make this worse
&lt;/h2&gt;&lt;p&gt;Schema drift in the wide-table direction is what language models reinforce by default. A model generating &lt;code&gt;ALTER TABLE&lt;/code&gt; for a feature request reads the current schema and proposes the smallest change that makes the feature work, which is almost always adding columns to the table that already holds the related data. Proposing a split requires understanding the access pattern, the transaction boundaries, and the write frequency of the new columns versus the existing ones. None of that is in the &lt;code&gt;CREATE TABLE&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The loop reinforces itself: the wider the table gets, the more natural it is for the next change to widen it further. &amp;ldquo;Where do loyalty tier and tier expiry go?&amp;rdquo; The model sees &lt;code&gt;customers&lt;/code&gt; has every other user-attached concept in it and adds two columns. The alternative (&lt;code&gt;CREATE TABLE customer_loyalty (customer_id PK FK, tier, expires_at)&lt;/code&gt;) requires the model to argue for a split, and splits are rare in the training data compared to additions because splits are rare in real codebases for the same reason: they&amp;rsquo;re harder to ship than additions. The model is correctly pattern-matching on what humans actually do, which is exactly the problem.&lt;/p&gt;
&lt;p&gt;ORMs compound this. One model equals one table is the default shape in ActiveRecord, Django ORM, Prisma, SQLAlchemy, and Ecto. Refactoring a &lt;code&gt;Customer&lt;/code&gt; model into three co-owned tables is a change that touches every query, every serializer, every test. The ORM makes &amp;ldquo;add a column to the existing model&amp;rdquo; a five-line change and &amp;ldquo;split the model&amp;rdquo; a project. Engineers pick the cheap option every time, and the wide table ratchets.&lt;/p&gt;
&lt;h2 id="split-by-access-pattern-not-by-concept"&gt;Split by access pattern, not by concept
&lt;/h2&gt;&lt;p&gt;&amp;ldquo;Normalize it&amp;rdquo; isn&amp;rsquo;t the fix because normalization is a property of data shape, not query cost. The fix is to look at what columns are actually read and written together, and keep those co-located; the rest moves out.&lt;/p&gt;
&lt;p&gt;A workable decomposition for the &lt;code&gt;customers&lt;/code&gt; example:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Core hot table.&lt;/strong&gt; The columns read on nearly every query: &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;email&lt;/code&gt;, &lt;code&gt;name&lt;/code&gt;, &lt;code&gt;status&lt;/code&gt;, &lt;code&gt;tier&lt;/code&gt;, &lt;code&gt;stripe_customer_id&lt;/code&gt;, &lt;code&gt;created_at&lt;/code&gt;. Maybe twenty columns. This is what the list view, the auth path, and most API responses need.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;1:1 cold tables.&lt;/strong&gt; Concerns that are read rarely or in specific flows: &lt;code&gt;customer_audit&lt;/code&gt; for login/seen/purchase timestamps, &lt;code&gt;customer_preferences&lt;/code&gt; for user settings, &lt;code&gt;customer_feature_flags&lt;/code&gt; for the twelve TINYINT flags. Each is a separate table with &lt;code&gt;customer_id&lt;/code&gt; as PK and FK, joined only when the flow actually needs it. Writes to &lt;code&gt;last_login_at&lt;/code&gt; stop rewriting the billing row.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;1:N tables for repeating groups.&lt;/strong&gt; Addresses, payment methods, anything that was modeled as &lt;code&gt;shipping_address_2&lt;/code&gt;, &lt;code&gt;shipping_address_3&lt;/code&gt;, &lt;code&gt;shipping_address_4&lt;/code&gt; is an &lt;code&gt;addresses&lt;/code&gt; table with a FK and a type. This collapses polymorphic-ish schema decisions that shouldn&amp;rsquo;t have been made at the column level in the first place; see &lt;a class="link" href="https://explainanalyze.com/p/polymorphic-references-are-not-foreign-keys/" &gt;Polymorphic References&lt;/a&gt; for the related pattern where doing this without a FK goes wrong.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The trade-off is that some queries now join two or three tables instead of reading one. On the hot path this is fine; the joins are on PK-equals-FK, the join tables are small, and the read is usually cheaper than scanning a fat row. The cold path is where it matters: the audit screen now joins &lt;code&gt;customers&lt;/code&gt; to &lt;code&gt;customer_audit&lt;/code&gt;, which costs one indexed lookup and nobody notices. The place to be careful is the query that reads from three of the split tables on every request. If that&amp;rsquo;s dominant, one of those tables probably belongs merged back in.&lt;/p&gt;
&lt;h2 id="when-a-wide-table-is-actually-fine"&gt;When a wide table is actually fine
&lt;/h2&gt;&lt;p&gt;Not every 100-column table is a god table. Three cases where width is defensible:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Analytical and reporting tables on columnar storage.&lt;/strong&gt; As noted above, warehouses like ClickHouse, BigQuery, and Redshift invert the cost calculus. Reading one column doesn&amp;rsquo;t load the rest, and the normalization pressure flips: denormalize aggressively because joins are expensive and per-column reads are cheap. This anti-pattern is specifically a row-store OLTP problem.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Small tables that stay small.&lt;/strong&gt; A &lt;code&gt;tenants&lt;/code&gt; table with 80 columns and 500 rows fits entirely in the buffer pool. The write amplification is paid a few thousand times a day, not a few million. The secondary-index cost is negligible because the indexes are small. Width matters when row count is large enough for the per-row cost to dominate; on small tables it doesn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Every query reads every column.&lt;/strong&gt; Uncommon but real. If the dominant read is &amp;ldquo;fetch the full customer record for display&amp;rdquo; and the split would produce a join that runs on every request anyway, the split doesn&amp;rsquo;t help. The test is whether the queries you actually run touch disjoint column sets. If they do, the split has a real win; if they don&amp;rsquo;t, it&amp;rsquo;s architecture for its own sake.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;Relational databases aren&amp;rsquo;t built for developer convenience. They&amp;rsquo;re built for storage efficiency and retrieval speed: narrow rows, well-placed indexes, joins on indexed keys, query plans that read only what they need. Normalization isn&amp;rsquo;t an academic ideal; it&amp;rsquo;s the shape that lines up with how the engine actually pays its bills. Every cost mechanism in this post (buffer-pool density, write amplification, index bloat, row-lock width) is the engine reporting the same thing in different dialects: the shape you&amp;rsquo;re asking it to hold isn&amp;rsquo;t the shape it was optimized for.&lt;/p&gt;
&lt;p&gt;God tables are the limit of a sequence of rational local decisions where the global cost is invisible at each step. The column count of a mature production table is usually a decent proxy for how long the team has been making the cheap choice, which is most teams most of the time, and that is not by itself a failure. The failure is that the cost goes uncounted. A 6KB row is a write-amplification multiplier on every UPDATE, a buffer-pool multiplier on every read, and an index-size multiplier on every secondary index. None of those costs are on the PR that adds a column; all of them are on the dashboard that shows p99 drifting up quarter after quarter.&lt;/p&gt;
&lt;p&gt;The lever is to count the cost at the system level when the table hits a certain width (pick a threshold: sixty columns, a hundred, whatever fits) and make the next column addition a conversation about whether this concern belongs here, not a line in a migration. The answer is often still yes, but it shouldn&amp;rsquo;t be the default answer.&lt;/p&gt;</description></item><item><title>Covering Index Traps: When Adding One Column Breaks Your Query</title><link>https://explainanalyze.com/p/covering-index-traps-when-adding-one-column-breaks-your-query/</link><pubDate>Fri, 18 Jul 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/covering-index-traps-when-adding-one-column-breaks-your-query/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Covering Index Traps: When Adding One Column Breaks Your Query" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;An index-only scan is the fastest way a relational database can answer a query: the engine reads the index and never touches the table. Adding a single column to the SELECT list that isn&amp;rsquo;t in the index silently breaks the optimization, and the query that ran in a millisecond now takes seconds. The SELECT list is part of the query&amp;rsquo;s performance contract with the index.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Here&amp;rsquo;s a query that ran in production for a year with sub-millisecond latency:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The &lt;code&gt;orders&lt;/code&gt; table has a composite index on &lt;code&gt;(customer_id, status, created_at)&lt;/code&gt;. Every column the query needs (&lt;code&gt;customer_id&lt;/code&gt; for the filter, &lt;code&gt;status&lt;/code&gt; and &lt;code&gt;created_at&lt;/code&gt; for the output) is in that index. The database reads the index, returns the results, and never touches the table. This is an index-only scan: one of the most significant optimizations a relational engine makes, and the mechanism behind &amp;ldquo;covering&amp;rdquo; queries.&lt;/p&gt;
&lt;p&gt;Then a feature request: &amp;ldquo;show the order total on this page.&amp;rdquo; The change looks trivial.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total_cents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;One column added. The query is still correct. The index still matches the filter. &lt;code&gt;total_cents&lt;/code&gt; isn&amp;rsquo;t in the index, so for every matching row, the engine now follows a pointer back to the table to fetch that one extra column. On a table with millions of rows, that&amp;rsquo;s a random I/O per match. The query that was 0.4 ms is now 1243 ms.&lt;/p&gt;
&lt;p&gt;The obvious fix is &amp;ldquo;just don&amp;rsquo;t add columns to queries.&amp;rdquo; That doesn&amp;rsquo;t work; features need data. The slightly-less-obvious fix is &amp;ldquo;always project the minimum columns,&amp;rdquo; which is fine as advice and ignored in practice because every ORM defaults to &lt;code&gt;SELECT *&lt;/code&gt;. The actual fix is to treat the SELECT list as part of the query&amp;rsquo;s performance contract with the index, and to know what that contract is before changing it.&lt;/p&gt;
&lt;h2 id="whats-actually-happening"&gt;What&amp;rsquo;s actually happening
&lt;/h2&gt;&lt;p&gt;The execution plan tells the whole story:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Before: index-only scan
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Index Only Scan using idx_orders_cust_status_created on orders
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Heap Fetches: 0
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Execution Time: 0.4 ms
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- After: index scan + table lookups
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total_cents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Index Scan using idx_orders_cust_status_created on orders
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Execution Time: 1243.7 ms
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Same index. Same filter. Same rows returned. The only difference is the select list, and it moves the query from a pure index walk to an index walk plus one random I/O per matching row.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Buffer pool pollution compounds the damage.&lt;/strong&gt; When the engine fetches full rows from the table instead of reading compact index entries, it loads entire data pages into the buffer pool. Those pages (carrying every column of every matched row, most of which the query doesn&amp;rsquo;t need) evict pages that other queries do need. On a busy system with a finite buffer pool, one query losing its covering index degrades performance for unrelated queries across the database. The slow query you noticed is rarely the only thing getting slower.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Nothing in the query results tells you.&lt;/strong&gt; The rows come back correctly. The response looks the same. A &lt;code&gt;SELECT COUNT(*)&lt;/code&gt; returns the same count. The only place the degradation is visible is in the execution plan, and nobody checks the execution plan when the feature ships.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;ORM defaults&lt;/strong&gt;
 &lt;div&gt;Most ORMs emit &lt;code&gt;SELECT *&lt;/code&gt; unless explicitly told otherwise. ActiveRecord needs &lt;code&gt;.select(:id, :status)&lt;/code&gt;; Django needs &lt;code&gt;.only('id', 'status')&lt;/code&gt;; SQLAlchemy needs explicit column specification; Prisma needs an explicit &lt;code&gt;select&lt;/code&gt; block. On a high-traffic table, a one-line change to project only the needed columns is one of the highest-leverage optimizations available. Worth checking what your ORM actually generates on the query paths that matter; the generated SQL is the contract, not the method call.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="the-fix-match-the-index-or-extend-it"&gt;The fix: match the index or extend it
&lt;/h2&gt;&lt;p&gt;There are two workable fixes when a query loses its covering index, and they trade different costs:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Project only what the index covers.&lt;/strong&gt; If the new column isn&amp;rsquo;t worth fetching from the table on every row, don&amp;rsquo;t fetch it. Split the query: one covered query for the list view, a targeted lookup for the detail row the user actually wants. Most feature requests that &amp;ldquo;need&amp;rdquo; an extra column on a list page are actually fine with lazy-loading the value on click.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Extend the index to include the new column.&lt;/strong&gt; If the column is genuinely needed on every row, add it to the index, either as an additional indexed column or (in PostgreSQL) as an &lt;code&gt;INCLUDE&lt;/code&gt; clause that adds the value to the leaf pages without making it part of the B-tree ordering:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- PostgreSQL: add total_cents as a non-key included column
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INDEX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;idx_orders_cust_status_created_total&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;INCLUDE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_cents&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;INCLUDE&lt;/code&gt; is the right tool when you need the column covered but don&amp;rsquo;t want it affecting the sort order or filter path. The trade-off is write cost: the index is now larger, and every update to &lt;code&gt;total_cents&lt;/code&gt; has to update the index entry. On a write-heavy table that&amp;rsquo;s meaningful; on a read-heavy table it&amp;rsquo;s usually negligible compared to the read speedup.&lt;/p&gt;
&lt;p&gt;MySQL (InnoDB) doesn&amp;rsquo;t support &lt;code&gt;INCLUDE&lt;/code&gt; but has a natural equivalent: every secondary index already contains the primary key at its leaves, and you can extend the secondary index to cover additional columns by adding them as regular key columns. The planner is smart enough to use the covered form when the column is present.&lt;/p&gt;
&lt;h2 id="when-covering-isnt-the-right-call"&gt;When covering isn&amp;rsquo;t the right call
&lt;/h2&gt;&lt;p&gt;Covering indexes aren&amp;rsquo;t a universal good. Three cases where chasing a covering index is the wrong move:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Low-selectivity filters.&lt;/strong&gt; If &lt;code&gt;customer_id = 42&lt;/code&gt; matches 80% of the table, the planner won&amp;rsquo;t use the index at all; a sequential scan is cheaper. Index-only scans matter when the filter is selective. On a low-selectivity predicate, covering changes nothing.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Write-heavy tables.&lt;/strong&gt; Every index slows writes. A table taking 50,000 inserts per second with five secondary indexes already pays a real cost for every index entry. Adding a covering variant of an existing index to shave read latency from 15 ms to 3 ms is a bad trade if the table is write-dominated; the write penalty compounds on every row, and only the reads benefit.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Rapidly changing projections.&lt;/strong&gt; If the feature team is adding and removing columns from the list view every sprint, chasing the covering index is a losing game. Freeze the list-view columns as a contract, document them in the schema, and let the index match that contract, or don&amp;rsquo;t bother indexing for coverage at all.&lt;/p&gt;
&lt;h2 id="one-more-column-silently-uncovered"&gt;One more column, silently uncovered
&lt;/h2&gt;&lt;p&gt;The archetypal AI-generated version of this bug is a one-line change that adds a column to the SELECT list. A feature request says &amp;ldquo;show the order total on the list page&amp;rdquo;; the assistant reads the existing query, adds &lt;code&gt;total_cents&lt;/code&gt; to the projection, and returns the patch. The query still runs, the list page still renders, and the p99 quietly moves from 0.4 ms to 1200 ms because the index-only scan became a heap-fetch scan and nobody noticed until the dashboard did.&lt;/p&gt;
&lt;p&gt;Coverage checking itself isn&amp;rsquo;t hard reasoning. Given a query and an index definition, working out whether the SELECT list stays inside the index is a short syntactic check any capable model can do. The catalog exposes the ingredients: PostgreSQL&amp;rsquo;s &lt;code&gt;pg_index&lt;/code&gt; separates key columns from &lt;code&gt;INCLUDE&lt;/code&gt; ones via &lt;code&gt;indnkeyatts&lt;/code&gt; vs &lt;code&gt;indnatts&lt;/code&gt;, MySQL&amp;rsquo;s &lt;code&gt;information_schema.STATISTICS&lt;/code&gt; lists all columns per index. The signal is there. What fails in practice is subtler. The relevant index often isn&amp;rsquo;t in the prompt&amp;rsquo;s context window; schema-aware tools pull catalog metadata, but whether &lt;code&gt;idx_orders_cust_status_created&lt;/code&gt; lands in the retrieved context for &amp;ldquo;add total_cents to the list view&amp;rdquo; depends on retrieval heuristics, not the model&amp;rsquo;s capability. Even when the index definition is available, the default behavior for &amp;ldquo;modify this query&amp;rdquo; is to modify the query; re-verifying that the projection stays covered is a step the assistant rarely takes unsolicited. And only the planner&amp;rsquo;s actual choice is authoritative; static analysis gets most of the way, but nothing short of &lt;code&gt;EXPLAIN&lt;/code&gt; tells you which index the query will use under real statistics.&lt;/p&gt;
&lt;p&gt;The fix at the schema level is what makes the coverage relationship legible to the next reader, human or model: name indexes after the query they support (&lt;code&gt;idx_orders_list_view&lt;/code&gt; tells you what depends on it), document &lt;code&gt;INCLUDE&lt;/code&gt; columns in the index comment, and put a comment on the query itself pointing at the index. None of this is novel advice. It becomes load-bearing once an assistant is routinely modifying queries: the explicit link between query and covering index is the signal that tells the assistant (and the human reviewer) &amp;ldquo;this change has an index implication&amp;rdquo; rather than silently shipping the uncovered patch.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;The SELECT list is a performance contract in most code reviewers&amp;rsquo; blind spot. WHERE clauses get scrutinized because they&amp;rsquo;re obviously performance-relevant. JOINs get scrutinized because cardinality mistakes are visible. The SELECT list gets waved through because &amp;ldquo;it&amp;rsquo;s just what we display&amp;rdquo;, and then a one-column addition drops a query from 0.4 ms to 1243 ms with no code-review signal to catch it.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; is the only authority here. Reading execution plans isn&amp;rsquo;t glamorous, but it&amp;rsquo;s the difference between a query that works and a query that works at scale, and between a select-list change that&amp;rsquo;s free and one that silently broke the optimization the index existed to enable. On the queries that carry the most traffic, the execution plan belongs in code review alongside the query itself.&lt;/p&gt;</description></item><item><title>Legacy Schemas Are Sediment, Not Design</title><link>https://explainanalyze.com/p/legacy-schemas-are-sediment-not-design/</link><pubDate>Tue, 01 Jul 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/legacy-schemas-are-sediment-not-design/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Legacy Schemas Are Sediment, Not Design" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;A legacy schema looks like a design and reads like a sediment: layers of decisions from different eras, where names that once described the data no longer do and conventions that look uniform aren&amp;rsquo;t. Renaming is prohibitively expensive once every caller depends on the current names. The workable fix is documenting the drift so the next reader (human or LLM) can navigate what&amp;rsquo;s actually there.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;A new engineer joins the team and reads the schema. &lt;code&gt;tmp_orders&lt;/code&gt; looks like scaffolding, something to delete once the real migration ships. The tech lead answers: never delete it. &lt;code&gt;tmp_orders&lt;/code&gt; is the main orders table. The temp-to-permanent rename was planned for 2017, nobody shipped it, and every service in the company now writes to the table. The name is a lie the schema tells every new reader, and every LLM generating SQL against the catalog.&lt;/p&gt;
&lt;p&gt;The obvious fix is to rename the table. Nothing about the database itself prevents it: drop the &lt;code&gt;tmp_&lt;/code&gt; prefix, update every call site, ship. The reality is that every service, ORM model, report, integration, and runbook references &lt;code&gt;tmp_orders&lt;/code&gt; by name. The rename is a multi-quarter effort that crosses team boundaries, and the only justification is legibility. Teams rarely prioritize legibility work, so the name stays, and the schema keeps lying.&lt;/p&gt;
&lt;h2 id="whats-drifted"&gt;What&amp;rsquo;s drifted
&lt;/h2&gt;&lt;p&gt;Legacy drift shows up in three visible modes and one invisible one.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Names that stopped describing the data.&lt;/strong&gt; &lt;code&gt;tmp_&lt;/code&gt; tables that are permanent. &lt;code&gt;old_&lt;/code&gt; columns that are current. &lt;code&gt;deprecated_&lt;/code&gt; fields that every write path still populates. &lt;code&gt;flag1&lt;/code&gt;, &lt;code&gt;flag2&lt;/code&gt;, &lt;code&gt;status_code&lt;/code&gt;: names whose meaning was obvious when the column was added, because the person adding it remembered why. By the time a new reader arrives, the intent is gone and the name is false advertising. &lt;a class="link" href="https://explainanalyze.com/p/comment-your-schema/" &gt;Comment Your Schema&lt;/a&gt; covers the documentation side of this; legacy schemas are the case where comments would help most and where they&amp;rsquo;re most often absent.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Conventions per era.&lt;/strong&gt; The 2014-era backend team used &lt;code&gt;camelCase&lt;/code&gt;. The 2019 rewrite adopted &lt;code&gt;snake_case&lt;/code&gt;. The 2022 microservice added a third table with &lt;code&gt;PascalCase&lt;/code&gt; because the Go team wrote it and nobody pushed back. Now one database has &lt;code&gt;userId&lt;/code&gt;, &lt;code&gt;user_id&lt;/code&gt;, and &lt;code&gt;UserID&lt;/code&gt;, all referring to the same entity across different tables. The LLM that generates &lt;code&gt;business.created_at&lt;/code&gt; when the column is actually &lt;code&gt;business.createdDate&lt;/code&gt; isn&amp;rsquo;t wrong in any sense the schema could catch; it&amp;rsquo;s inferring a convention from one table and applying it to another, which is a reasonable thing to do in a schema that has only one convention.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Tables that were supposed to be temporary.&lt;/strong&gt; &lt;code&gt;tmp_orders&lt;/code&gt; is the canonical example, but every long-lived database has some. Staging tables that got promoted to production. Migration tables that weren&amp;rsquo;t cleaned up. &amp;ldquo;Phase 2&amp;rdquo; tables built for a transitional period that shipped in phase 1 and never came back to finish. The names encode the original intent; the data encodes the current reality; the two diverge a little more with every migration that preserves the name instead of fixing it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Invisible structural drift.&lt;/strong&gt; Charsets and collations are the version of drift that doesn&amp;rsquo;t even show up in the column list. Older tables created before the Unicode migration default to &lt;code&gt;latin1&lt;/code&gt;; newer tables use &lt;code&gt;utf8mb4&lt;/code&gt;. A join between a &lt;code&gt;VARCHAR(100)&lt;/code&gt; column in one table and a &lt;code&gt;VARCHAR(100)&lt;/code&gt; column in another (both with the same name, both with the same logical meaning) silently produces different results depending on which side&amp;rsquo;s collation MySQL picks. In the bad cases, an implicit charset conversion kills index usage and turns the query into a table scan. &lt;code&gt;SHOW TABLE STATUS&lt;/code&gt; reveals this; reading the column list doesn&amp;rsquo;t. Most LLMs read the column list.&lt;/p&gt;
&lt;h2 id="why-this-is-worse-for-llms-than-for-humans"&gt;Why this is worse for LLMs than for humans
&lt;/h2&gt;&lt;p&gt;A new human engineer working with a legacy schema can ask. They can ping the on-call channel, look up the original migration in git, trace a column back to the PR that introduced it, or simply ask &amp;ldquo;what is &lt;code&gt;flag1&lt;/code&gt;?&amp;rdquo; and get an answer from someone who knows. The answer is often wrong or outdated, but it&amp;rsquo;s a starting point, and the engineer learns to treat the schema with appropriate suspicion.&lt;/p&gt;
&lt;p&gt;An LLM generating SQL from the catalog has no such recourse. It sees &lt;code&gt;tmp_orders&lt;/code&gt; and reasons from the name (probably &amp;ldquo;this is a staging table, prefer the non-tmp version if one exists, otherwise deprioritize&amp;rdquo;). It sees &lt;code&gt;old_price&lt;/code&gt; and treats it as historical. It sees &lt;code&gt;flag1 BOOLEAN&lt;/code&gt; and infers a generic flag. Each inference is reasonable; each is wrong in the specific case; the schema gives no signal that this is one of the cases where reasoning from the name produces bad SQL.&lt;/p&gt;
&lt;p&gt;This is the sharper version of the &lt;a class="link" href="https://explainanalyze.com/p/the-bare-id-primary-key-when-every-table-joins-to-every-other-table/" &gt;generic &lt;code&gt;id&lt;/code&gt; primary key&lt;/a&gt; problem. Both are failures of the schema to describe itself. The PK case hides what&amp;rsquo;s being matched; legacy drift hides what anything means. Neither failure shows up at write time; both produce queries that run, return data, and look plausible, because the rows exist and the types match. The wrongness is in the interpretation, which the database has no way to check.&lt;/p&gt;
&lt;h2 id="the-fix-is-documentation-not-renaming"&gt;The fix is documentation, not renaming
&lt;/h2&gt;&lt;p&gt;The obvious fix (rename everything to match intent and convention) fails on cost. Every table, column, and constraint in a mature schema is referenced by services the team has forgotten about: scheduled jobs, Redshift imports, third-party integrations, BI dashboards built by a contractor in 2019, runbooks pasted into wiki pages that nobody has edited since. A rename that looks like a one-line migration touches every surface the table is exposed on, and the projects that survive the attempt usually take a year and leave the schema worse during the transition.&lt;/p&gt;
&lt;p&gt;The workable fix is to stop the drift from continuing and make the existing drift visible. Stopping new drift means picking a convention for new tables and columns and writing it down where CI can enforce it (&lt;a class="link" href="https://explainanalyze.com/p/schema-conventions-dont-survive-without-automation/" &gt;Schema Conventions and Why They Matter&lt;/a&gt; covers the mechanics). Making existing drift visible means &lt;a class="link" href="https://explainanalyze.com/p/comment-your-schema/" &gt;column and table comments&lt;/a&gt; on everything whose name doesn&amp;rsquo;t match its meaning, plus a per-era mapping somewhere in the repo that says &amp;ldquo;this database has four naming conventions, used in these periods, applied to these tables.&amp;rdquo; Legacy schemas are the case where &lt;code&gt;COMMENT ON&lt;/code&gt; pays off highest. The names are already wrong, the cost of fixing them is prohibitive, and the comment is the one affordable signal the next reader gets.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tmp_orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Main orders table. The tmp_ prefix is historical: a 2017 migration was planned to rename this and was never completed. Do not drop.&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COLUMN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;flag1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;VIP customer flag. Legacy name from the 2014 schema; never renamed because of external reporting dependencies.&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;One-line migrations, zero risk, and every reader (human and LLM) now has a chance of reading the schema correctly. This isn&amp;rsquo;t a fix in the sense of &amp;ldquo;problem solved.&amp;rdquo; It&amp;rsquo;s a fix in the sense of &amp;ldquo;the next reader has a chance.&amp;rdquo; The drift is structural; the documentation is how you navigate it without making it worse.&lt;/p&gt;
&lt;h2 id="when-a-clean-rewrite-is-actually-worth-it"&gt;When a clean rewrite is actually worth it
&lt;/h2&gt;&lt;p&gt;Renames and migrations aren&amp;rsquo;t always wrong. Three cases where the rewrite earns its cost:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A misleading name is actively causing incidents.&lt;/strong&gt; If &lt;code&gt;tmp_orders&lt;/code&gt; is regularly truncated or dropped by someone who reads the name literally and acts on it, the rename cost is less than the recovery cost from the next incident. Usually the practical fix here isn&amp;rsquo;t a rename; it&amp;rsquo;s a view, synonym, or ALTER-TABLE-RENAME that exposes &lt;code&gt;orders&lt;/code&gt; as the canonical name and leaves &lt;code&gt;tmp_orders&lt;/code&gt; as a compatibility alias for legacy callers.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A schema migration is happening anyway.&lt;/strong&gt; If the team is replatforming the OLTP database or splitting it across services, the rewrite opens a window where renames are cheap because callers are being updated either way. Take the opportunity; don&amp;rsquo;t schedule a separate naming cleanup six months later when the window has closed.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;A database small enough that it fits one person&amp;rsquo;s head.&lt;/strong&gt; Early-stage startups, internal tools, bounded-scope services. At twenty tables and three developers, a Saturday afternoon of renames is cheaper than a decade of comments.&lt;/p&gt;
&lt;p&gt;In every other case, the schema is load-bearing history, and you renovate it the way you renovate a building with people still living in it: patch, document, and schedule the demolition for a window when it&amp;rsquo;s genuinely cheap.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;Every production schema is a compressed record of the decisions the team made under pressure. Some of those decisions were good and still fit; some were good at the time and don&amp;rsquo;t fit now; some were expedient and nobody noticed. The schema can&amp;rsquo;t tell you which is which, and it was never going to. The aspiration isn&amp;rsquo;t a clean schema that doesn&amp;rsquo;t accumulate history (no such schema exists past a three-year horizon) but enough signal for the next reader to decompress the sediment without guessing.&lt;/p&gt;
&lt;p&gt;Comment the columns that lie. Document the conventions per era. Treat LLMs generating SQL against the catalog as the same kind of reader a new engineer is, and give them the same written context.&lt;/p&gt;</description></item><item><title>Non-SARGable Predicates: How a Function in WHERE Kills Your Index</title><link>https://explainanalyze.com/p/non-sargable-predicates-how-a-function-in-where-kills-your-index/</link><pubDate>Sat, 14 Jun 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/non-sargable-predicates-how-a-function-in-where-kills-your-index/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Non-SARGable Predicates: How a Function in WHERE Kills Your Index" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;A predicate is SARGable (Search ARGument able) if the database can use an index to evaluate it. Wrapping a column in a function makes the predicate non-SARGable: the engine has to compute the function on every row before it can filter, which means a full table scan no matter what indexes exist. The fix isn&amp;rsquo;t always to rewrite the predicate (sometimes the column&amp;rsquo;s type or collation is wrong and the code is masking it) but every non-SARGable predicate on a hot path is a performance bug waiting for the table to grow.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Here are two queries that return the exact same rows:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Version A
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;YEAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2025&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Version B
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2025-01-01&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2026-01-01&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;On a 10,000-row &lt;code&gt;events&lt;/code&gt; table, both run in under a millisecond and nobody notices the difference. On a 200-million-row &lt;code&gt;events&lt;/code&gt; table with an index on &lt;code&gt;created_at&lt;/code&gt;, version A does a sequential scan and takes 45 seconds; version B does an index range scan and takes 12 milliseconds. Neither query is wrong. They don&amp;rsquo;t even disagree about the answer. One just does the same work in a way the planner can&amp;rsquo;t optimize.&lt;/p&gt;
&lt;p&gt;The obvious fix is &amp;ldquo;rewrite every function-wrapped predicate as a range.&amp;rdquo; That works for the date-extraction case and a few others. For &lt;code&gt;WHERE LOWER(email) = 'alice@example.com'&lt;/code&gt;, the rewrite needs to know whether the column&amp;rsquo;s collation is case-insensitive, and if it isn&amp;rsquo;t, there&amp;rsquo;s no direct equivalent, only a functional index or a schema change. The fix depends on why the function is there, and &amp;ldquo;why&amp;rdquo; usually points back at something in the schema that&amp;rsquo;s pretending to be something it isn&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="what-sargable-means-in-practice"&gt;What SARGable means in practice
&lt;/h2&gt;&lt;p&gt;An index on &lt;code&gt;created_at&lt;/code&gt; is a sorted structure: the engine can jump to any date range in O(log n) time by walking the B-tree. For the planner to use that index on a predicate, the predicate has to be expressible as &amp;ldquo;the column is in this range&amp;rdquo;: a direct comparison between the column and a constant or parameter.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;created_at &amp;gt;= '2025-01-01'&lt;/code&gt; meets that contract. The planner translates it to &amp;ldquo;walk the index to the first entry ≥ 2025-01-01, read forward from there.&amp;rdquo; That&amp;rsquo;s a range scan.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;YEAR(created_at) = 2025&lt;/code&gt; doesn&amp;rsquo;t meet the contract. The value being compared isn&amp;rsquo;t &lt;code&gt;created_at&lt;/code&gt;; it&amp;rsquo;s the output of &lt;code&gt;YEAR()&lt;/code&gt; applied to &lt;code&gt;created_at&lt;/code&gt;. The index on &lt;code&gt;created_at&lt;/code&gt; doesn&amp;rsquo;t know the output of &lt;code&gt;YEAR()&lt;/code&gt; for any row without computing it. So the planner falls back to evaluating the function on every row (a sequential scan) and only then filtering.&lt;/p&gt;
&lt;p&gt;Common forms of the same mistake:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Non-SARGable: function on column → full scan
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;alice@example.com&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2025-01-15&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;CAST&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;first_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39; &amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;last_name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Alice Smith&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- SARGable equivalents
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;alice@example.com&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- if collation is case-insensitive
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2025-01-15&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2025-01-16&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;price&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- fix the type at the schema level
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;first_name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Alice&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;last_name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Smith&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Three of the four non-SARGable forms have clean rewrites. The first one (&lt;code&gt;LOWER(email)&lt;/code&gt;) depends on collation, which is where a lot of real-world cases live.&lt;/p&gt;
&lt;h2 id="the-collation-case"&gt;The collation case
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;WHERE LOWER(email) = 'alice@example.com'&lt;/code&gt; is almost always a tell that the &lt;code&gt;email&lt;/code&gt; column has a case-sensitive collation and the application is hiding it at query time. Two real fixes, one cosmetic fix:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Fix the column.&lt;/strong&gt; If the data should be matched case-insensitively, give the column a case-insensitive collation. In PostgreSQL that&amp;rsquo;s &lt;code&gt;CITEXT&lt;/code&gt; or a &lt;code&gt;COLLATE &amp;quot;und-x-icu&amp;quot;&lt;/code&gt; with the ICU provider; in MySQL it&amp;rsquo;s a &lt;code&gt;_ci&lt;/code&gt; collation (which is usually the default anyway). Once the column&amp;rsquo;s collation handles the case folding, &lt;code&gt;WHERE email = 'alice@example.com'&lt;/code&gt; is SARGable and fast. This is the right fix when case-insensitivity is a property of the data.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Add a functional (expression) index.&lt;/strong&gt; If you can&amp;rsquo;t change the column&amp;rsquo;s collation (there&amp;rsquo;s a case-sensitive comparison elsewhere in the schema that depends on the current behavior) index the expression itself:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- PostgreSQL: functional index
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INDEX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;idx_users_email_lower&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Now WHERE LOWER(email) = &amp;#39;...&amp;#39; uses the index
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- MySQL 8.0+: expression index (requires the same constant-folding fix)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ADD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INDEX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;idx_email_lower&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;((&lt;/span&gt;&lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)));&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;This works, with caveats. The index&amp;rsquo;s storage and write cost is real. The predicate has to match the indexed expression exactly: &lt;code&gt;LOWER(email)&lt;/code&gt; is indexed, but &lt;code&gt;UPPER(email)&lt;/code&gt; isn&amp;rsquo;t, and the planner won&amp;rsquo;t translate between them. Every non-SARGable expression you want fast needs its own index.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Cosmetic fix: case-fold at write time.&lt;/strong&gt; Store the email as already-lowercased. &lt;code&gt;WHERE email = 'alice@example.com'&lt;/code&gt; is now SARGable directly, no expression index needed. This usually requires application changes (whoever&amp;rsquo;s writing has to remember to case-fold) which is why the functional index is more popular even though it&amp;rsquo;s heavier. &lt;a class="link" href="https://explainanalyze.com/p/where-business-logic-lives-database-vs.-application/" &gt;Where business logic lives&lt;/a&gt; covers the general shape of this decision; case-folding at the database with a generated column (&lt;code&gt;GENERATED ALWAYS AS (LOWER(email)) STORED&lt;/code&gt;) is often the cleanest answer when the application can&amp;rsquo;t be trusted to normalize consistently.&lt;/p&gt;
&lt;h2 id="implicit-type-conversions-are-the-subtler-version"&gt;Implicit type conversions are the subtler version
&lt;/h2&gt;&lt;p&gt;The function isn&amp;rsquo;t always in the query. Sometimes the planner is adding one:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- account_id is VARCHAR, literal is numeric
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;account_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12345&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;MySQL will silently cast every &lt;code&gt;account_id&lt;/code&gt; value to a number for comparison: a per-row function call that kills index usage just as effectively as an explicit &lt;code&gt;CAST()&lt;/code&gt;. PostgreSQL is stricter and usually errors, but can still do implicit conversions between compatible types that undermine indexes.&lt;/p&gt;
&lt;p&gt;The fix is matching types in both directions: the column type should be what the column is (a numeric ID should be &lt;code&gt;BIGINT&lt;/code&gt;, not &lt;code&gt;VARCHAR&lt;/code&gt;), and the query should write the literal in the column&amp;rsquo;s type (&lt;code&gt;WHERE account_id = '12345'&lt;/code&gt; if the column is genuinely a string). Either fix works; matching the column type to the data&amp;rsquo;s real shape is usually the durable answer.&lt;/p&gt;
&lt;p&gt;This is also where &lt;a class="link" href="https://explainanalyze.com/p/the-bare-id-primary-key-when-every-table-joins-to-every-other-table/" &gt;mixed PK strategies&lt;/a&gt; show up. Joining a BIGINT &lt;code&gt;id&lt;/code&gt; to a UUID &lt;code&gt;id&lt;/code&gt; doesn&amp;rsquo;t just return wrong results; on MySQL it coerces one side to a string, which is the same implicit-function problem dressed up as a join.&lt;/p&gt;
&lt;h2 id="when-non-sargable-is-acceptable"&gt;When non-SARGable is acceptable
&lt;/h2&gt;&lt;p&gt;Not every non-SARGable predicate is a bug. Three cases where it&amp;rsquo;s fine:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Small tables.&lt;/strong&gt; A 5,000-row lookup table with a function-wrapped predicate scans in microseconds. The planner isn&amp;rsquo;t going to use an index on that size anyway. &lt;code&gt;WHERE UPPER(code) = 'NY'&lt;/code&gt; on a 50-row &lt;code&gt;states&lt;/code&gt; table is not worth worrying about.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;One-off analytical queries.&lt;/strong&gt; A one-time data extract that scans a large table is going to scan it regardless. If the query will never run again, the function call isn&amp;rsquo;t the bottleneck (the table size is) and adding a functional index to optimize one query isn&amp;rsquo;t worth the write cost on every future insert.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;When the function genuinely can&amp;rsquo;t be avoided.&lt;/strong&gt; Some predicates legitimately need to compute. &lt;code&gt;WHERE haversine_distance(lat, lng, user_lat, user_lng) &amp;lt; 10&lt;/code&gt; on a geospatial query can&amp;rsquo;t be rewritten as a simple range; you need a spatial index (PostGIS, MySQL spatial extensions) to make it SARGable in the geometric sense. The fix is a different kind of index, not a rewrite.&lt;/p&gt;
&lt;h2 id="why-natural-language-to-sql-tilts-non-sargable"&gt;Why natural-language-to-SQL tilts non-SARGable
&lt;/h2&gt;&lt;p&gt;Schema-reading assistants and text-to-SQL models produce this class of bug more often than hand-written queries do. A user asks &amp;ldquo;events in 2025&amp;rdquo;; the closest English-to-SQL mapping is &lt;code&gt;WHERE YEAR(created_at) = 2025&lt;/code&gt;, and that&amp;rsquo;s what the model writes. The correct form (a half-open range) requires knowing the calendar boundary of the year and producing two comparison operators, which is a less-direct translation of the question. &lt;code&gt;WHERE LOWER(email) = 'alice@example.com'&lt;/code&gt; is the natural translation of &amp;ldquo;find the user with this email, case-insensitive,&amp;rdquo; even when the column&amp;rsquo;s collation already handles case and the function wrap defeats the index it would otherwise use.&lt;/p&gt;
&lt;p&gt;The catalog-level fix is the same one the bigger-picture section below points at: model the column so the natural query is already SARGable. Pick a case-insensitive collation on &lt;code&gt;email&lt;/code&gt;, store prices as &lt;code&gt;NUMERIC&lt;/code&gt; so no cast is needed, partition or index date columns so the range-literal form performs. When the schema matches the shape of the question, the model&amp;rsquo;s default translation works. When it doesn&amp;rsquo;t, the model produces a query that runs clean and scans the table, and no plan inspection is built into the generation loop to catch it.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;Non-SARGable predicates are easy to write, and they come from somewhere: almost always a schema decision that&amp;rsquo;s being papered over at query time. &lt;code&gt;LOWER(email)&lt;/code&gt; hides a collation mismatch. &lt;code&gt;CAST(price AS INT)&lt;/code&gt; hides a type that should have been &lt;code&gt;NUMERIC&lt;/code&gt; from the start. &lt;code&gt;DATE(created_at)&lt;/code&gt; hides the fact that the query is answering a date-range question but written in a way that reads more naturally as an equality. Every one of these is a query-level workaround for a schema-level issue, and every one of them costs an index when the table grows large enough to care.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; is the diagnostic. If the plan shows a sequential scan on a predicate that should hit an index, the predicate is almost certainly non-SARGable; look at what&amp;rsquo;s wrapping the column. Fix the schema if you can, add a functional index if you can&amp;rsquo;t, and treat non-SARGable predicates on hot paths as latent performance bugs, not style issues.&lt;/p&gt;</description></item><item><title>The Bare `id` Primary Key: When Every Table Joins to Every Other Table</title><link>https://explainanalyze.com/p/the-bare-id-primary-key-when-every-table-joins-to-every-other-table/</link><pubDate>Tue, 27 May 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/the-bare-id-primary-key-when-every-table-joins-to-every-other-table/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post The Bare `id` Primary Key: When Every Table Joins to Every Other Table" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;A bare &lt;code&gt;id&lt;/code&gt; primary key on every table makes &lt;code&gt;a.id = b.id&lt;/code&gt; valid SQL between any two tables, which means neither a human reviewing the query nor an LLM generating one can tell which of those equalities are meaningful. Name primary keys after the table they identify, and the schema describes its own relationships.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Here&amp;rsquo;s a query an AI assistant generated against a real production schema:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;payload&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;actions&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;alice@example.com&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Syntactically clean. Ran without error. Returned zero rows, which the assistant reported back as &amp;ldquo;this user has no actions.&amp;rdquo; The real answer: &lt;code&gt;users.id&lt;/code&gt; is a &lt;code&gt;BIGINT&lt;/code&gt; and &lt;code&gt;actions.id&lt;/code&gt; is a &lt;code&gt;CHAR(36)&lt;/code&gt; UUID. MySQL coerced the integer to a string, compared it to a UUID, and found no match. The join wasn&amp;rsquo;t wrong, exactly. It was meaningless, and the database had no way to say so.&lt;/p&gt;
&lt;p&gt;The experienced reader&amp;rsquo;s first fix is &amp;ldquo;just use UUIDs everywhere&amp;rdquo; or &amp;ldquo;enforce the type at join time.&amp;rdquo; Neither works. The footgun isn&amp;rsquo;t the type mismatch; it&amp;rsquo;s the column name. When every table&amp;rsquo;s primary key is named &lt;code&gt;id&lt;/code&gt;, &lt;code&gt;a.id = b.id&lt;/code&gt; is a valid expression between any two tables in the schema, and nothing in the column names tells you whether that expression means anything. Fix the types and you close one failure mode; the identically-typed, semantically-unrelated &lt;code&gt;users.id = 42 = orders.id&lt;/code&gt; case still ships.&lt;/p&gt;
&lt;h2 id="what-nobody-can-see"&gt;What nobody can see
&lt;/h2&gt;&lt;p&gt;The &lt;code&gt;&amp;lt;table&amp;gt;_id&lt;/code&gt; convention is older than most of us, and the case for it is usually framed as clarity or style. The sharper framing is that bare &lt;code&gt;id&lt;/code&gt; hides the information that matters most at the point of the join (which table&amp;rsquo;s identity is being compared, and whether comparing them makes sense) from every reader of the query.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The query&amp;rsquo;s reviewer.&lt;/strong&gt; &lt;code&gt;ON u.id = a.id&lt;/code&gt; gives no hint of what&amp;rsquo;s being matched. A human reviewer has to carry the table-to-alias mapping (&lt;code&gt;u&lt;/code&gt; is &lt;code&gt;users&lt;/code&gt;, &lt;code&gt;a&lt;/code&gt; is &lt;code&gt;actions&lt;/code&gt;) and the table-to-type mapping (&lt;code&gt;users.id&lt;/code&gt; is BIGINT, &lt;code&gt;actions.id&lt;/code&gt; is UUID) in working memory, then cross-check them against the join condition. None of those steps are hard, but reviewers skip them because the column names look symmetric. Two &lt;code&gt;.id&lt;/code&gt; references read as &amp;ldquo;joining on primary keys,&amp;rdquo; which is the kind of join nobody flags.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The LLM reading the schema.&lt;/strong&gt; An assistant generating SQL from the catalog sees &lt;code&gt;users(id BIGINT, ...)&lt;/code&gt; and &lt;code&gt;actions(id CHAR(36), ...)&lt;/code&gt; as two tables with primary keys named &lt;code&gt;id&lt;/code&gt;. Absent a full column-type check on every candidate join (and most schema-reading prompts don&amp;rsquo;t do this), the natural-looking join between &amp;ldquo;a user and their actions&amp;rdquo; is &lt;code&gt;u.id = a.id&lt;/code&gt;, which is exactly wrong. The schema presented the column as joinable; the LLM took it at face value. The same mistake a tired human makes, but at scale and without fatigue to blame.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;The static analyzer.&lt;/strong&gt; Linters and schema-aware query builders operate on names first and types second. A rule that warns on suspicious cross-table joins has no signal to fire on when both sides are &lt;code&gt;.id&lt;/code&gt;; the column names match, so the join is &amp;ldquo;legitimate&amp;rdquo; by shape. The same rule on &lt;code&gt;users.user_id = actions.action_id&lt;/code&gt; would flag it immediately, because the names would be obviously non-corresponding.&lt;/p&gt;
&lt;p&gt;None of these readers are missing a step they should have taken. They&amp;rsquo;re all doing the reasonable thing, and the reasonable thing produces wrong queries because the schema is telling them &lt;code&gt;id&lt;/code&gt; is &lt;code&gt;id&lt;/code&gt; in both tables.&lt;/p&gt;
&lt;h2 id="three-failure-modes-ranked-by-how-loudly-they-fail"&gt;Three failure modes, ranked by how loudly they fail
&lt;/h2&gt;&lt;p&gt;Three distinct outcomes hide behind &lt;code&gt;a.id = b.id&lt;/code&gt;, and they don&amp;rsquo;t fail equally:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PostgreSQL, mixed types.&lt;/strong&gt; The comparison errors out with &lt;code&gt;operator does not exist: bigint = uuid&lt;/code&gt;. Loud, caught in development, fixed before merge. The best failure mode.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MySQL, mixed types.&lt;/strong&gt; Silent coercion to string, zero rows returned. The opening example. Bad, because &amp;ldquo;no results&amp;rdquo; looks like valid data to every downstream consumer.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Either engine, same type but semantically unrelated.&lt;/strong&gt; &lt;code&gt;BIGINT users.id = 42&lt;/code&gt; matched against &lt;code&gt;BIGINT orders.id = 42&lt;/code&gt; returns the rows where the integers happen to collide. The query runs, the result set isn&amp;rsquo;t empty, and the rows look plausible because they&amp;rsquo;re real rows from real tables. The worst failure mode, because nothing about the output signals that the join was nonsense.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The first two are loud enough to catch in review. The third is the one that ships. The third is the default once more than one table in the schema uses a plain &lt;code&gt;BIGINT&lt;/code&gt; &lt;code&gt;id&lt;/code&gt;, which is almost every relational schema in existence.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Zero rows looks like no data&lt;/strong&gt;
 &lt;div&gt;A join that silently returns zero rows because of a type coercion is indistinguishable from a join that legitimately has no matches. Code generators, dashboards, and AI assistants all interpret empty results as &amp;ldquo;the relationship exists but has no rows,&amp;rdquo; not &amp;ldquo;the query is nonsense.&amp;rdquo; The failure hides inside success.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="mixed-pk-types-make-the-naming-problem-sharper"&gt;Mixed PK types make the naming problem sharper
&lt;/h2&gt;&lt;p&gt;Production schemas rarely stay on one PK strategy for long. The original tables are usually &lt;code&gt;BIGINT AUTO_INCREMENT&lt;/code&gt; because the framework defaulted to it; a newer service switches to UUIDs to let clients generate IDs offline or to distribute across shards; join tables pick up composite keys because &lt;code&gt;(user_id, role_id)&lt;/code&gt; is the natural identity. Nothing in the schema announces which tables fall into which bucket; &lt;code&gt;SHOW CREATE TABLE&lt;/code&gt; or &lt;code&gt;\d&lt;/code&gt; is the only source of truth, and even that requires reading every table to know what joins are legal.&lt;/p&gt;
&lt;p&gt;Mixed types are where the naming footgun turns from theoretical to frequent. When every PK was a BIGINT, the &amp;ldquo;same type but semantically unrelated&amp;rdquo; case was the main risk and reviewers caught most of it. Once the schema has BIGINT and UUID sitting next to each other (all named &lt;code&gt;id&lt;/code&gt;) the mismatched-type cases pile on top, and &amp;ldquo;no data found&amp;rdquo; becomes a regular report from any tool generating queries from the schema.&lt;/p&gt;
&lt;p&gt;The sizing question (when to pick BIGINT versus UUID versus UUIDv7 versus composite, and what each costs at the index level) is covered separately in &lt;a class="link" href="https://explainanalyze.com/p/random-uuids-as-primary-keys-the-b-tree-penalty/" &gt;Random UUIDs as Primary Keys&lt;/a&gt;. The two problems interact but have independent fixes: pick your PK types deliberately, and name them so the schema describes its own relationships. Neither fix substitutes for the other.&lt;/p&gt;
&lt;h2 id="naming-is-the-lever-that-actually-helps"&gt;Naming is the lever that actually helps
&lt;/h2&gt;&lt;p&gt;Naming is what makes a schema describe its own relationships without requiring the reader (human or otherwise) to open every &lt;code&gt;CREATE TABLE&lt;/code&gt;. Two conventions, consistently applied, close most of the gap:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Name the primary key after the table.&lt;/strong&gt; &lt;code&gt;users.user_id&lt;/code&gt;, &lt;code&gt;orders.order_id&lt;/code&gt;, &lt;code&gt;actions.action_id&lt;/code&gt;. The equality &lt;code&gt;users.user_id = orders.order_id&lt;/code&gt; reads as obvious nonsense, because the column names are no longer identical. Reviewers see it, LLMs don&amp;rsquo;t produce it, linters can flag it. The cost is a small amount of redundancy in queries (&lt;code&gt;users.user_id&lt;/code&gt; instead of &lt;code&gt;users.id&lt;/code&gt;), which is almost always a fair trade. This lines up with the broader guidance in &lt;a class="link" href="https://explainanalyze.com/p/schema-conventions-dont-survive-without-automation/" &gt;Schema Conventions and Why They Matter&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Foreign keys mirror the target PK.&lt;/strong&gt; &lt;code&gt;orders.user_id&lt;/code&gt; clearly references &lt;code&gt;users.user_id&lt;/code&gt;. &lt;code&gt;actions.user_id&lt;/code&gt; clearly references &lt;code&gt;users.user_id&lt;/code&gt;. This is already common practice; the only change is that the target&amp;rsquo;s PK name matches, closing the loop. &lt;a class="link" href="https://explainanalyze.com/p/foreign-keys-are-not-optional/" &gt;Foreign Keys Are Not Optional&lt;/a&gt; covers why the FK itself matters; naming is what makes the FK legible without the &lt;code&gt;REFERENCES&lt;/code&gt; clause in hand.&lt;/p&gt;
&lt;p&gt;The bare &lt;code&gt;id&lt;/code&gt; convention is defensible when the PK column only ever shows up in queries alongside its table name (&lt;code&gt;users.id&lt;/code&gt;) and never as a bare &lt;code&gt;id&lt;/code&gt; in a SELECT list or join condition. That discipline is hard to enforce across a team over years, and every framework&amp;rsquo;s default query builder produces &lt;code&gt;SELECT id FROM users&lt;/code&gt; without thinking about it. The naming fix makes the discipline unnecessary.&lt;/p&gt;
&lt;h2 id="when-bare-id-is-actually-fine"&gt;When bare &lt;code&gt;id&lt;/code&gt; is actually fine
&lt;/h2&gt;&lt;p&gt;Not every schema needs to bend. A small application, a service with a handful of tables, or a database where every query is reviewed by one team has plenty of context to keep the &lt;code&gt;a.id = b.id&lt;/code&gt; landmine out of reach. The cost of the convention scales with the number of tables, the number of engineers, and the number of non-human query generators; in the small case it rarely shows up.&lt;/p&gt;
&lt;p&gt;What changes once any of those numbers grow: nobody remembers which tables are BIGINT versus UUID, the assistant pattern of generating queries from schema is routine, and the review process that caught &lt;code&gt;a.id = b.id&lt;/code&gt; in a 20-table schema can&amp;rsquo;t read every join in a 400-table one. At that size the convention pays rent, and renaming PKs is a migration that gets slower every quarter.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;A schema&amp;rsquo;s job is to hold data correctly and describe its own shape well enough that the tools reading it can reason about relationships without reading every line. The bare &lt;code&gt;id&lt;/code&gt; PK is a small departure from that (one column name shared across tables) but it&amp;rsquo;s the departure that most consistently produces silent-wrong-answer queries, because SQL has no way to distinguish &amp;ldquo;same name, same meaning&amp;rdquo; from &amp;ldquo;same name, different meaning.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Name the primary key after the table it identifies, so the schema tells its own story when someone (human or otherwise) joins two of them together. It costs almost nothing on day one and leaves the schema legible at 400 tables.&lt;/p&gt;</description></item><item><title>Polymorphic References Are Not Foreign Keys</title><link>https://explainanalyze.com/p/polymorphic-references-are-not-foreign-keys/</link><pubDate>Sat, 10 May 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/polymorphic-references-are-not-foreign-keys/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Polymorphic References Are Not Foreign Keys" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;A polymorphic reference is &lt;code&gt;resource_id&lt;/code&gt; plus &lt;code&gt;resource_type&lt;/code&gt; where the type string chooses which table the ID points to. ORMs make it a one-liner; the database enforces nothing. Reads need conditional joins, orphans accumulate silently, and for most uses (comments, notifications, attachments) per-target tables or mutually-exclusive FKs are the better trade.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="what-the-pattern-looks-like"&gt;What the pattern looks like
&lt;/h2&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;notifications&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;resource_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DEFAULT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- resource_type = &amp;#39;order&amp;#39; → resource_id references orders.id
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- resource_type = &amp;#39;invoice&amp;#39; → resource_id references invoices.id
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- resource_type = &amp;#39;ticket&amp;#39; → resource_id references support_tickets.id
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The tell is &lt;code&gt;resource_id BIGINT NOT NULL&lt;/code&gt; with no &lt;code&gt;REFERENCES&lt;/code&gt; clause; it can&amp;rsquo;t have one, because there are multiple targets. What the application treats as a foreign key is, at the database level, a plain integer with a sibling tag string.&lt;/p&gt;
&lt;h2 id="what-the-database-cant-do"&gt;What the database can&amp;rsquo;t do
&lt;/h2&gt;&lt;p&gt;The cost shows up as absence: every mechanism the database offers for reasoning about relationships is disabled, because the column&amp;rsquo;s meaning depends on data in another column.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;No foreign key.&lt;/strong&gt; A &lt;code&gt;REFERENCES&lt;/code&gt; clause names exactly one target. Orphaned &lt;code&gt;resource_id&lt;/code&gt; values are a write-time non-event and a read-time mystery. (&lt;a class="link" href="https://explainanalyze.com/p/foreign-keys-are-not-optional/" &gt;Foreign Keys Are Not Optional&lt;/a&gt; covers the general cost; polymorphic is the case where skipping isn&amp;rsquo;t a choice.)&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No cascade.&lt;/strong&gt; Delete an order and nothing cleans up the notifications pointing at it. The application has to know every table that might hold a polymorphic reference to &lt;code&gt;orders&lt;/code&gt; and clean each one. New tables added later don&amp;rsquo;t get noticed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No planner metadata.&lt;/strong&gt; Foreign keys feed join ordering and row estimates, especially in PostgreSQL. The planner sees &lt;code&gt;resource_id&lt;/code&gt; as a &lt;code&gt;BIGINT&lt;/code&gt; with a histogram and no known target.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;No schema-level description.&lt;/strong&gt; Anything that reads the catalog (ERD tools, query generators, AI assistants, typed-client generators) sees no link between &lt;code&gt;notifications.resource_id&lt;/code&gt; and the tables it points at. The mapping lives in model files and string literals. (&lt;a class="link" href="https://explainanalyze.com/p/comment-your-schema/" &gt;Comment Your Schema&lt;/a&gt; helps here but can&amp;rsquo;t fully restore the information.)&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Orphans accumulate silently&lt;/strong&gt;
 &lt;div&gt;A polymorphic column with no FK and no cascade develops orphans over time. Reads paper over them with &lt;code&gt;LEFT JOIN ... WHERE target.id IS NOT NULL&lt;/code&gt;, so the broken rows disappear from the UI but stay in the table. In schemas a few years old, the orphan rate is rarely zero, and nobody designed for it.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="reads-pay-for-the-write-side-convenience"&gt;Reads pay for the write-side convenience
&lt;/h2&gt;&lt;p&gt;The absent FK is the schema problem. The read-path shape is where the cost becomes daily. A query that needs any column from the referenced row can&amp;rsquo;t write a single join; the target depends on a per-row value, and SQL&amp;rsquo;s join syntax takes a static target.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;span class="lnt"&gt;9
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Conditional LEFT JOIN per target
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;invoice_number&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ticket_code&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ref&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;notifications&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LEFT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;order&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LEFT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;invoices&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;invoice&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;i&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LEFT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;support_tickets&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;ticket&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;resource_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;t&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Every new target type adds a join clause here and in every other read-path query that displays a related field. The alternative (a &lt;code&gt;UNION ALL&lt;/code&gt; per target) is narrower per branch but scales linearly with target count and pushes pagination up to the union level. Most ORMs&amp;rsquo; default resolution is one query per &lt;code&gt;(resource_type, resource_id)&lt;/code&gt; group, which is the N+1 pattern that makes polymorphic feeds slow once the target set widens.&lt;/p&gt;
&lt;p&gt;&amp;ldquo;One column can point at many tables&amp;rdquo; on the write side turns into &amp;ldquo;every read query enumerates every possible table&amp;rdquo; on the read side. The symmetry people expect isn&amp;rsquo;t there.&lt;/p&gt;
&lt;h2 id="why-the-pattern-spreads"&gt;Why the pattern spreads
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s the path of least resistance that framework ergonomics encourage. Rails&amp;rsquo; &lt;code&gt;polymorphic: true&lt;/code&gt;, Django&amp;rsquo;s &lt;code&gt;GenericForeignKey&lt;/code&gt;, and Laravel&amp;rsquo;s &lt;code&gt;morphTo&lt;/code&gt; make one-liner what would otherwise be multiple &lt;code&gt;belongs_to&lt;/code&gt; associations and a migration. &amp;ldquo;Comments on orders&amp;rdquo; and &amp;ldquo;comments on invoices&amp;rdquo; look like duplication, so a single &lt;code&gt;comments&lt;/code&gt; table with &lt;code&gt;commentable_id&lt;/code&gt; / &lt;code&gt;commentable_type&lt;/code&gt; feels cleaner. An open-ended &amp;ldquo;add comments to anything&amp;rdquo; product ask reads as an argument against committing to a target list.&lt;/p&gt;
&lt;p&gt;Each of those framings overweights the write-side cost (another table or another FK column) and underweights the integrity loss (no enforcement, no cascades, schema no longer describes itself). &lt;a class="link" href="https://explainanalyze.com/p/orms-are-a-coupling-not-an-abstraction/" &gt;ORMs Are a Coupling&lt;/a&gt; covers the broader trade. Polymorphic is the canonical case where the ORM&amp;rsquo;s preferred shape is actively incompatible with what the database wants to enforce.&lt;/p&gt;
&lt;h2 id="what-the-schema-reading-assistant-sees"&gt;What the schema-reading assistant sees
&lt;/h2&gt;&lt;p&gt;A tool reading the catalog (Copilot on a schema dump, an MCP-backed agent, a RAG pipeline indexing DDL) sees &lt;code&gt;notifications.resource_id BIGINT NOT NULL&lt;/code&gt; with no &lt;code&gt;REFERENCES&lt;/code&gt; clause and no way to tell the column is anything other than an integer. Asked for &amp;ldquo;notifications about orders,&amp;rdquo; the assistant&amp;rsquo;s best guess is &lt;code&gt;notifications.resource_id = orders.id&lt;/code&gt;: a join that runs clean, returns every notification whose &lt;code&gt;resource_id&lt;/code&gt; happens to collide with an order ID (which includes invoice notifications, ticket notifications, and anything else pointing at an integer that also appears in &lt;code&gt;orders&lt;/code&gt;), and surfaces plausible-looking but semantically nonsense rows. The &lt;code&gt;resource_type&lt;/code&gt; filter that would make the join correct is the piece the schema doesn&amp;rsquo;t advertise.&lt;/p&gt;
&lt;p&gt;This is the structural version of the problem covered in &lt;a class="link" href="https://explainanalyze.com/p/the-bare-id-primary-key-when-every-table-joins-to-every-other-table/" &gt;the bare &lt;code&gt;id&lt;/code&gt; primary key&lt;/a&gt;: schema that can&amp;rsquo;t describe its own relationships forces every reader to guess, and schema-reading models guess confidently. Pulling the polymorphic column apart (per-target tables, mutually-exclusive FKs, supertype) restores the signal in the catalog. The assistant stops hallucinating the join; any RAG system indexing the schema picks up real &lt;code&gt;REFERENCES&lt;/code&gt; metadata; the next engineer reading the table doesn&amp;rsquo;t need to grep the ORM models to find out which target types exist. The integrity win and the catalog-legibility win come in the same migration.&lt;/p&gt;
&lt;h2 id="alternatives"&gt;Alternatives
&lt;/h2&gt;&lt;p&gt;Each alternative gives back some of the database&amp;rsquo;s relational machinery at different levels of verbosity.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Per-target tables.&lt;/strong&gt; Split along the target dimension: &lt;code&gt;order_notifications&lt;/code&gt;, &lt;code&gt;invoice_notifications&lt;/code&gt;, &lt;code&gt;ticket_notifications&lt;/code&gt;, each with a real FK. Real cascades, real planner metadata, self-describing schema. Cost: duplicated column sets and an explicit &lt;code&gt;UNION ALL&lt;/code&gt; for cross-target reads. That union already exists implicitly in the polymorphic shape, just moved from the read query into typed branches.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Mutually-exclusive nullable FKs with &lt;code&gt;CHECK&lt;/code&gt;.&lt;/strong&gt; One table, one FK column per target, a constraint enforcing exactly one is non-null:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;notifications&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;invoice_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;invoices&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;support_tickets&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;message&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;CONSTRAINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;exactly_one_target&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;CHECK&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;invoice_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ticket_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;)::&lt;/span&gt;&lt;span class="nb"&gt;int&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Real FKs per target, real cascades, row&amp;rsquo;s meaning unambiguous. Scales reasonably up to a handful of targets and stops scaling cleanly somewhere around ten.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Supertype table.&lt;/strong&gt; A shared parent table carries a common ID; each target type&amp;rsquo;s table references the parent. The polymorphic column then points at the parent, which is a single real FK. Cleanest structural answer and the one with the highest adoption cost; retrofitting this onto an existing schema is substantial migration work.&lt;/p&gt;
&lt;h2 id="when-polymorphic-is-actually-the-right-call"&gt;When polymorphic is actually the right call
&lt;/h2&gt;&lt;p&gt;The trade-offs stack up unfavorably for most common uses, but not all. The pattern earns its keep when the relationship is genuinely best-effort: audit events, activity logs, &amp;ldquo;recently viewed&amp;rdquo; lists, undo history, where a lost reference is a recoverable annoyance rather than a correctness incident. The FK was never going to be load-bearing, and the polymorphic shape matches the actual semantics: &amp;ldquo;reference anything, and if it&amp;rsquo;s gone, show a tombstone.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Outside that zone the default bias should run the other way. A comment system with three possible parents is not a case for polymorphism; it&amp;rsquo;s a case for three comment tables or mutually-exclusive FK columns, with the ORM abstracting the read-side stitching.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;Polymorphic references are a specific case of a broader pattern: designs that move information out of the schema and into the application, in exchange for ergonomics in the model layer. The schema drifts from &amp;ldquo;self-describing relational structure&amp;rdquo; toward &amp;ldquo;indexed key-value store the application interprets.&amp;rdquo; That&amp;rsquo;s a legitimate position (DynamoDB and friends live there on purpose) but a relational database running on polymorphic associations is paying for a relational engine and choosing not to use most of what it offers.&lt;/p&gt;
&lt;p&gt;The pattern isn&amp;rsquo;t wrong. It&amp;rsquo;s an aggressive trade, priced on day one by the convenience of &lt;code&gt;polymorphic: true&lt;/code&gt; and on day three hundred by the silent orphan count, the conditional joins, and &lt;code&gt;resource_id BIGINT&lt;/code&gt; telling no one what the table is related to. Reach for it on purpose. Keep the option of pulling it back onto typed FK columns open, because the migrations away are slower the longer the schema has been pretending the reference isn&amp;rsquo;t there.&lt;/p&gt;</description></item><item><title>ORMs Are a Coupling, Not an Abstraction</title><link>https://explainanalyze.com/p/orms-are-a-coupling-not-an-abstraction/</link><pubDate>Wed, 23 Apr 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/orms-are-a-coupling-not-an-abstraction/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post ORMs Are a Coupling, Not an Abstraction" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;An ORM is a coupling between schema shape and code shape, not an abstraction over it. The coupling pays off in year one and compounds against you in year five. For long-lived OLTP systems, a thinner layer over raw SQL (sqlc, jOOQ, typed query builders) ages better.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;There&amp;rsquo;s a period early in a project where an ORM feels like pure upside. You define a model, the framework generates a migration, and &lt;code&gt;User.where(email: …)&lt;/code&gt; returns typed objects. No SQL to write, no mapping layer to maintain, no integration boilerplate. Five years later the same project has four migration directories, a model class with thirty custom methods overriding the ORM defaults, team memory of which relations are lazy-loaded and which aren&amp;rsquo;t, and a quarterly discussion about whether it&amp;rsquo;s time to upgrade Rails 4 to Rails 7 or skip straight to something else entirely.&lt;/p&gt;
&lt;p&gt;Somewhere between those two points, the ORM stopped being an abstraction and became a coupling: a bidirectional contract between schema and code that both sides have to honor for every change. The contract shapes more than how changes propagate. It also shapes the schema itself, because an ORM&amp;rsquo;s default output is a database structured like the class graph rather than one designed for the workload. Short-lived prototypes and simple CRUD apps still benefit from ORMs. The defensible use cases are narrower than the industry&amp;rsquo;s default deployment pattern suggests, and the coupling is real, durable, and consistently underestimated at the point a team decides to adopt one.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;The oddity worth pausing on&lt;/strong&gt;
 &lt;div&gt;SQL is arguably the most widely-deployed, longest-lived programming language in the industry. Every major database speaks it, every backend engineer eventually learns it, the DDL and DML haven&amp;rsquo;t meaningfully changed in decades. The ORMs wrapping it are the opposite: framework-specific, tied to a particular version of a particular stack, with conventions that differ across ecosystems and shift across major releases. The default across most engineering orgs is to go out of their way to adopt the less portable, less stable of the two and hide the more durable one behind it. A team joining a new project expects to relearn the ORM. Nobody expects to relearn &lt;code&gt;SELECT&lt;/code&gt;.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The rest of this post offers one answer for why that&amp;rsquo;s the default: the coupling an ORM introduces hides its cost long enough that the trade looks very different in year one than it does in year five.&lt;/p&gt;
&lt;h2 id="what-the-orm-is-actually-doing"&gt;What the ORM is actually doing
&lt;/h2&gt;&lt;p&gt;The word &amp;ldquo;ORM&amp;rdquo; suggests abstraction, &amp;ldquo;object-relational mapping&amp;rdquo; as if the mapping is the hidden plumbing. The practical reality is the opposite: the mapping is the product. An ORM takes your schema shape and projects it onto code shape. Columns become fields. Tables become classes. Foreign keys become methods. Indexes are invisible until you care about them. Constraints are whatever the ORM&amp;rsquo;s DSL exposes and nothing more.&lt;/p&gt;
&lt;p&gt;That projection is useful. It lets application code avoid SQL, most of the time. It also means the code and schema are now two views of the same data model, and those views are expected to stay in sync by you, by your migration framework, by your tests, and by every developer who touches either side.&lt;/p&gt;
&lt;p&gt;Stay in sync, in practice, means every schema change is also a code change. Every code change that adds a field triggers a schema change. Every migration is a coordinated edit across multiple files. The coupling isn&amp;rsquo;t an implementation detail; it&amp;rsquo;s the defining characteristic of the tool.&lt;/p&gt;
&lt;h2 id="source-of-truth-pick-one-know-which"&gt;Source of truth: pick one, know which
&lt;/h2&gt;&lt;p&gt;Every ORM ecosystem has a default answer to &amp;ldquo;where does the schema canonically live&amp;rdquo;, and most teams never think about it.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Model-first.&lt;/strong&gt; Rails and Django generate migrations from changes to model classes. The model is the source of truth; the schema follows. Running &lt;code&gt;rails db:schema:dump&lt;/code&gt; produces a &lt;code&gt;schema.rb&lt;/code&gt; that describes the current state, and the migration files are the history of how it got there.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Schema-first.&lt;/strong&gt; &lt;a class="link" href="https://sqlc.dev/" target="_blank" rel="noopener"
 &gt;sqlc&lt;/a&gt; and &lt;a class="link" href="https://www.jooq.org/" target="_blank" rel="noopener"
 &gt;jOOQ&lt;/a&gt; read SQL DDL files and generate typed client code. The schema is the source of truth; the code follows.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hybrid / unclear.&lt;/strong&gt; Hibernate can do either, depending on configuration. SQLAlchemy lets you declare models in Python and generate migrations via Alembic, or point Alembic at an existing schema and generate models. Teams that don&amp;rsquo;t decide end up doing both.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The hybrid case is where the real damage happens. Over years, a team that migrates from model-first to schema-first (or vice versa) without a clean cutover ends up with a schema that neither the models nor the migration history correctly describes. Rows backfilled by a DBA with direct SQL don&amp;rsquo;t show up in the ORM&amp;rsquo;s understanding of the world. Columns added by a production hotfix get rediscovered six months later when someone regenerates models from the database.&lt;/p&gt;
&lt;p&gt;The fix isn&amp;rsquo;t to prefer one approach over the other. It&amp;rsquo;s to decide, document, and enforce, the way you would any other convention.&lt;/p&gt;
&lt;h2 id="migrations-stop-being-db-work"&gt;Migrations stop being &amp;ldquo;DB work&amp;rdquo;
&lt;/h2&gt;&lt;p&gt;In a raw-SQL codebase, a schema migration is a single file: &lt;code&gt;CREATE TABLE&lt;/code&gt;, &lt;code&gt;ALTER TABLE&lt;/code&gt;, &lt;code&gt;DROP COLUMN&lt;/code&gt;. The migration is the change.&lt;/p&gt;
&lt;p&gt;In an ORM codebase, a single logical schema change is typically:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A migration file (&lt;code&gt;add_email_to_users.rb&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;The model class (&lt;code&gt;User#email&lt;/code&gt; getter, validation, &lt;code&gt;serialize&lt;/code&gt; calls).&lt;/li&gt;
&lt;li&gt;The serializer (&lt;code&gt;UserSerializer#email&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;The API contract (OpenAPI spec, GraphQL schema, whatever the team uses).&lt;/li&gt;
&lt;li&gt;Fixtures and factories (FactoryBot, factory_boy, test data).&lt;/li&gt;
&lt;li&gt;Query helpers that need to know the new column.&lt;/li&gt;
&lt;li&gt;Type stubs or generated types (TypeScript declarations, Python stubs).&lt;/li&gt;
&lt;li&gt;Admin UI config, sometimes.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;What should be a single metadata-level change is now a coordinated edit across five to eight files, and missing any one of them produces a subtly broken application. The ORM didn&amp;rsquo;t create the complexity; it distributed it. The schema change is still one change. It just has to be propagated to every place the code has a mirror of the schema.&lt;/p&gt;
&lt;p&gt;At small scale this is fine. The friction compounds once the team is big enough that the people writing the migration aren&amp;rsquo;t the same people owning the serializers and the API consumers. A schema change now requires coordinating across teams, each with their own view of the data model, each needing their files updated. The schema itself didn&amp;rsquo;t get harder to change. The ORM layer around it did.&lt;/p&gt;
&lt;h2 id="hidden-queries"&gt;Hidden queries
&lt;/h2&gt;&lt;p&gt;The ORM generates SQL you didn&amp;rsquo;t write. That&amp;rsquo;s the value proposition. It&amp;rsquo;s also a persistent failure mode.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Lazy loading.&lt;/strong&gt; &lt;code&gt;user.orders&lt;/code&gt; triggers a query. &lt;code&gt;user.orders.first.line_items&lt;/code&gt; triggers another. In a loop over 100 users, that&amp;rsquo;s at least 101 queries, none of them visible in the code. The classic N+1.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Implicit joins.&lt;/strong&gt; &lt;code&gt;.includes(:orders)&lt;/code&gt; eager-loads associations, but only if someone remembers to write it. The default is lazy. Defaults win.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Magic methods.&lt;/strong&gt; &lt;code&gt;where(status: :active).first_or_create(email: …)&lt;/code&gt; is three or four queries depending on the code path, and the code says nothing about it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Generated sort and filter.&lt;/strong&gt; &lt;code&gt;User.order(:created_at).limit(10)&lt;/code&gt; on a table without an index on &lt;code&gt;created_at&lt;/code&gt; does a full table scan. The query was generated by the ORM; the reviewer never saw it.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these are the ORM doing something wrong. They&amp;rsquo;re the ORM doing exactly what it said it would. The cost is that the SQL the database actually runs isn&amp;rsquo;t in version control, isn&amp;rsquo;t code-reviewed, and isn&amp;rsquo;t profiled until it shows up in slow-query logs. Every ORM codebase accumulates query shapes nobody intentionally wrote.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;The queries you don&amp;#39;t see&lt;/strong&gt;
 &lt;div&gt;The SQL emitted by an ORM is invisible until something breaks. Code review covers the method call; the database sees three joins and a subquery. Teams relying heavily on ORMs end up needing separate tooling (query logs, APM, &lt;code&gt;pg_stat_statements&lt;/code&gt;, &lt;code&gt;EXPLAIN&lt;/code&gt; on every slow path) just to know what&amp;rsquo;s actually running.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="two-query-languages-neither-complete"&gt;Two query languages, neither complete
&lt;/h2&gt;&lt;p&gt;Past the CRUD ceiling, every ORM codebase ends up with raw SQL living alongside ORM calls. Window functions, recursive CTEs, PostgreSQL &lt;code&gt;DISTINCT ON&lt;/code&gt;, &lt;code&gt;LATERAL&lt;/code&gt; joins, MySQL &lt;code&gt;INSERT ... ON DUPLICATE KEY UPDATE&lt;/code&gt; with complex update clauses, exclusion constraints, full-text search, spatial queries: the list of things awkward or impossible to express through the ORM grows over the life of the project.&lt;/p&gt;
&lt;p&gt;The result is a codebase with two query languages coexisting. Reviewers have to know both. Type safety is uneven; ORM calls produce typed objects, raw SQL produces hashes or arrays that need manual mapping. The two styles drift. The ORM-side queries follow the ORM&amp;rsquo;s conventions; the raw-SQL queries follow whatever the author happened to write that day.&lt;/p&gt;
&lt;p&gt;The honest consequence: past a certain complexity threshold, the ORM isn&amp;rsquo;t reducing the SQL surface area, it&amp;rsquo;s adding a second layer on top of it. The SQL didn&amp;rsquo;t go away. It got pushed into the half of the codebase that&amp;rsquo;s harder to trace.&lt;/p&gt;
&lt;h2 id="bidirectional-coupling"&gt;Bidirectional coupling
&lt;/h2&gt;&lt;p&gt;The part that surprises teams is how hard it is to leave.&lt;/p&gt;
&lt;p&gt;Migrating a database schema (renaming a column, changing a type, splitting a table) is mechanical. It&amp;rsquo;s a migration file and a deploy window. The mechanics are well-understood and the blast radius is bounded.&lt;/p&gt;
&lt;p&gt;Migrating off an ORM is not mechanical. The ORM&amp;rsquo;s conventions have bled into:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Controller and API code.&lt;/strong&gt; JSON shapes match model attributes. &lt;code&gt;as_json&lt;/code&gt;, &lt;code&gt;serializable_hash&lt;/code&gt;, and ORM callbacks define what the outside world sees.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Test suites.&lt;/strong&gt; Fixtures, factories, and in-memory SQLite test databases depend on the ORM being there.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Third-party integrations.&lt;/strong&gt; Export formats, webhooks, analytics pipelines, all built against the ORM&amp;rsquo;s JSON representation of the data.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Admin UIs.&lt;/strong&gt; Rails Admin, Django Admin, Laravel Nova; hard-wired to specific ORM conventions.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Query helpers.&lt;/strong&gt; Every scope, every association, every callback is ORM-native.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Team knowledge.&lt;/strong&gt; Every engineer who&amp;rsquo;s been there more than a year thinks in the ORM&amp;rsquo;s abstractions.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of this is the database&amp;rsquo;s problem. It&amp;rsquo;s the surrounding code that grew up expecting the ORM to be there. Replacing the ORM means replacing or rewriting every one of those layers. A schema migration is a weekend project; an ORM migration is a yearlong initiative.&lt;/p&gt;
&lt;p&gt;The asymmetry is worth naming. The coupling is bidirectional, and one direction (schema → code) is much harder to undo than the other. Teams that adopt an ORM for velocity rarely account for the exit cost.&lt;/p&gt;
&lt;h2 id="database-side-logic-doesnt-round-trip"&gt;Database-side logic doesn&amp;rsquo;t round-trip
&lt;/h2&gt;&lt;p&gt;Most ORMs have a tunnel-vision view of the schema: they see what they created. They don&amp;rsquo;t see:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;CHECK&lt;/code&gt; constraints.&lt;/strong&gt; The ORM has no concept of them. A constraint like &lt;code&gt;CHECK (amount &amp;gt;= 0)&lt;/code&gt; is invisible to the model; the ORM&amp;rsquo;s validations become the only gatekeeper the application knows about.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Triggers.&lt;/strong&gt; A trigger that mutates a row after insert produces data the ORM didn&amp;rsquo;t know would be there. Reading back the row often requires an explicit reload.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Generated columns.&lt;/strong&gt; MySQL&amp;rsquo;s &lt;code&gt;GENERATED ALWAYS AS (…) STORED&lt;/code&gt; and PostgreSQL&amp;rsquo;s equivalent produce values the ORM treats as regular columns, but they can&amp;rsquo;t be written to, and the ORM&amp;rsquo;s default behavior is to try.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Partial and expression indexes.&lt;/strong&gt; The ORM sees the column, not the index. A query that should hit a partial index on &lt;code&gt;WHERE deleted_at IS NULL&lt;/code&gt; gets generated without that predicate and misses the index.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Exclusion constraints.&lt;/strong&gt; PostgreSQL &lt;code&gt;EXCLUDE USING gist (…)&lt;/code&gt;. Completely outside the ORM&amp;rsquo;s worldview.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The ORM&amp;rsquo;s view of the schema is a subset of the real schema. Queries written against that subset can violate invariants the database enforces. The application code thinks the write succeeded; the &lt;code&gt;INSERT&lt;/code&gt; comes back with a constraint violation; the code has no idea why. Teams paper over this with application-level validation that duplicates the database&amp;rsquo;s, and then the two drift, which is its own class of production incident.&lt;/p&gt;
&lt;h2 id="relational-modeling-isnt-object-modeling"&gt;Relational modeling isn&amp;rsquo;t object modeling
&lt;/h2&gt;&lt;p&gt;The coupling goes one direction that&amp;rsquo;s easy to see: schema changes require code changes. It also goes the other direction, which is harder to see. The ORM&amp;rsquo;s object model is what shapes the schema in the first place. For simple data, a &lt;code&gt;User&lt;/code&gt; with an email and a password hash, that&amp;rsquo;s fine. For non-trivial domains, the shape inherited from object modeling produces schemas that look like class hierarchies and perform like poorly-designed databases.&lt;/p&gt;
&lt;p&gt;This mismatch has a name: the object-relational impedance mismatch. Its practical consequence is that ORM-driven schemas get shaped by class hierarchies rather than by the relationships and access patterns the workload actually has.&lt;/p&gt;
&lt;p&gt;Normalization doesn&amp;rsquo;t look like inheritance. A properly normalized schema is structured by the shape of the relationships between entities, not by a class graph. Consider a scheduling application with three kinds of entries: appointments, days off, and product launches. All of them are events. They have a start time, an owner, a status. Each has different additional fields.&lt;/p&gt;
&lt;p&gt;The relational answer is a supertype/subtype pattern (sometimes called class table inheritance): a base &lt;code&gt;events&lt;/code&gt; table with the shared fields, and specialized tables for each subtype, each with &lt;code&gt;event_id&lt;/code&gt; as a primary key that&amp;rsquo;s also a foreign key back to &lt;code&gt;events&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;span class="lnt"&gt;20
&lt;/span&gt;&lt;span class="lnt"&gt;21
&lt;/span&gt;&lt;span class="lnt"&gt;22
&lt;/span&gt;&lt;span class="lnt"&gt;23
&lt;/span&gt;&lt;span class="lnt"&gt;24
&lt;/span&gt;&lt;span class="lnt"&gt;25
&lt;/span&gt;&lt;span class="lnt"&gt;26
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;events&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;starts_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ends_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;CHECK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;kind&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;appointment&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;day_off&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;launch&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;appointments&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;client_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;clients&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;location&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;days_off&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;reason&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;paid&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BOOLEAN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;launches&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;event_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DELETE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;CASCADE&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;audience&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Each subtype has its own columns, indexes, and constraints. Each can evolve independently. A new field on &lt;code&gt;appointments&lt;/code&gt; doesn&amp;rsquo;t touch &lt;code&gt;events&lt;/code&gt;, &lt;code&gt;days_off&lt;/code&gt;, or &lt;code&gt;launches&lt;/code&gt;. Dropping the &lt;code&gt;launches&lt;/code&gt; feature drops one table and a CHECK-constraint value. Queries that only care about one subtype hit a narrow, well-indexed table instead of scanning across fifty columns of mostly-null data.&lt;/p&gt;
&lt;p&gt;The ORM-driven shape tends to produce something different. Rails&amp;rsquo; single-table inheritance (STI) collapses everything into one wide table with a &lt;code&gt;type&lt;/code&gt; column and every possible subtype field nullable. Django&amp;rsquo;s multi-table inheritance is closer to the relational answer but introduces implicit joins the developer didn&amp;rsquo;t ask for. Hibernate offers all three strategies (&lt;code&gt;SINGLE_TABLE&lt;/code&gt;, &lt;code&gt;JOINED&lt;/code&gt;, &lt;code&gt;TABLE_PER_CLASS&lt;/code&gt;) but most teams pick &lt;code&gt;SINGLE_TABLE&lt;/code&gt; because it&amp;rsquo;s the default and the fastest for small-scale CRUD.&lt;/p&gt;
&lt;p&gt;STI-style tables start showing their cost around the 10-million-row mark. Every query now scans a table with dozens of nullable columns. Indexes have to include the &lt;code&gt;type&lt;/code&gt; column to be useful. Adding a field to one subtype means adding a nullable column visible to every other subtype. The schema looks like a class hierarchy and performs like one table doing the job of four.&lt;/p&gt;
&lt;p&gt;Complex relationships don&amp;rsquo;t fit class graphs. Many-to-many bridges with their own columns, polymorphic references (one column that points to different tables depending on a sibling column&amp;rsquo;s value), temporal tables, recursive self-references; once the data model has these, the object graph starts fraying. The ORM&amp;rsquo;s answer is usually a custom association that looks natural in code and generates SQL nobody would write by hand.&lt;/p&gt;
&lt;p&gt;Normalization decisions are driven by access patterns, not classes. A well-designed schema decides what to normalize and what to denormalize based on read/write ratios, query patterns, and storage trade-offs. The ORM-first approach tends to normalize by class structure, which is mostly correlated with good access-pattern normalization at small scale and mostly uncorrelated with it at scale.&lt;/p&gt;
&lt;p&gt;The coupling here isn&amp;rsquo;t only code to schema. It&amp;rsquo;s class-graph to schema-shape, and that second form is the one that dictates how the database performs under real traffic.&lt;/p&gt;
&lt;h2 id="when-scale-exposes-the-modeling"&gt;When scale exposes the modeling
&lt;/h2&gt;&lt;p&gt;The class-shaped schema is cheap at small scale. Its cost is hidden until the workload grows, and because the schema shape is coupled to the class graph the application assumes, fixing it isn&amp;rsquo;t a schema migration. It&amp;rsquo;s an application restructure. The ORM&amp;rsquo;s opinions about data modeling are fine at 1,000 rows. Tolerable at 1 million. Breaking at 10 million. At 100 million, the patterns that were quietly suboptimal become the production incidents of the quarter.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Wide STI tables&lt;/strong&gt; that scanned fine for 100k rows become the reason a query times out at 100M, because the planner can&amp;rsquo;t pick an efficient path through dozens of columns of mostly-null data with mixed cardinalities.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lazy-loaded associations&lt;/strong&gt; that were 200ms at small scale are now 60-second requests fanning out to a thousand queries.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;find_or_create_by&lt;/code&gt; races&lt;/strong&gt; that never mattered when two users hit the same endpoint now cause daily deadlocks on hot rows.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Unindexed ORM-generated sorts&lt;/strong&gt; that worked at 10k rows become sequential scans over hundreds of gigabytes.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Connection-pool exhaustion&lt;/strong&gt; from ORMs that hold connections across application logic becomes a top-of-funnel incident when traffic grows.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;At this point, teams reach for tools that weren&amp;rsquo;t supposed to be in the solution space for an OLTP application. Materialized views are the common one. They&amp;rsquo;re legitimately useful for analytical workloads, wrong for write-heavy OLTP because they have to be refreshed, and refresh windows during traffic either stall the primary or serve stale reads. Read replicas with application-level routing get bolted on not because the read workload demands it, but because the primary is buckling under queries that would have been cheap on a better-designed schema. Caching layers get introduced to paper over query shapes the ORM insists on generating. Each of these has legitimate uses. None of them is a fix for a schema that wasn&amp;rsquo;t designed for the access pattern it&amp;rsquo;s getting.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Materialized views aren&amp;#39;t an OLTP tool&lt;/strong&gt;
 &lt;div&gt;A materialized view is a precomputed query result stored as a table. In an OLTP system with heavy writes, the refresh cost either stalls the primary during the refresh or leaves the view stale. Neither is acceptable for a live application. Materialized views are an analytical-workload tool; reaching for them to fix an OLTP performance problem is a sign the underlying schema shape is wrong.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The pattern: ORM-driven schemas work until they don&amp;rsquo;t, and when they don&amp;rsquo;t, the options are rewrite the schema (hard, because the ORM&amp;rsquo;s conventions are everywhere) or add infrastructure that papers over the problem (expensive, and eventually stops working too). The schema that was designed to be ergonomic for the ORM at 1,000 rows is now the binding constraint on what the application can do at 100M.&lt;/p&gt;
&lt;h2 id="the-thinner-alternatives"&gt;The thinner alternatives
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s a spectrum between &amp;ldquo;hand-roll every query with &lt;code&gt;database/sql&lt;/code&gt;&amp;rdquo; and &amp;ldquo;full ORM with identity map, lazy loading, and 200-line models.&amp;rdquo; Several tools occupy the middle ground by treating SQL as the source of truth and generating typed code from it, without the mapping layer.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;a class="link" href="https://sqlc.dev/" target="_blank" rel="noopener"
 &gt;sqlc&lt;/a&gt;.&lt;/strong&gt; Go, Kotlin, Python, TypeScript. You write SQL queries in &lt;code&gt;.sql&lt;/code&gt; files; sqlc generates type-safe client code. The schema is canonical, the queries are code-reviewed SQL, and there&amp;rsquo;s no runtime layer to reason about. Migrations stay plain DDL.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a class="link" href="https://www.jooq.org/" target="_blank" rel="noopener"
 &gt;jOOQ&lt;/a&gt;.&lt;/strong&gt; JVM. Reads your schema and produces a fluent, type-safe DSL for building queries. Feels like SQL, reads like SQL, with compile-time type checking. Schema-first, no model mapping.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a class="link" href="https://kysely.dev/" target="_blank" rel="noopener"
 &gt;Kysely&lt;/a&gt;.&lt;/strong&gt; TypeScript. Typed query builder with no ORM layer. You describe the schema in types; Kysely ensures queries match. The full SQL surface area is reachable.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;a class="link" href="https://orm.drizzle.team/" target="_blank" rel="noopener"
 &gt;Drizzle&lt;/a&gt;.&lt;/strong&gt; TypeScript. Despite the name, closer to a typed query builder than a classical ORM. Schema declared in code, queries written in a SQL-like DSL, no identity map.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Plain &lt;code&gt;database/sql&lt;/code&gt; or &lt;code&gt;pgx&lt;/code&gt; with a small query helper.&lt;/strong&gt; Go in particular has a tradition of &amp;ldquo;raw SQL plus a thin wrapper.&amp;rdquo; More boilerplate, minimal coupling.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The common thread across these tools: schema is the source of truth, queries are code-reviewed first-class artifacts, and there&amp;rsquo;s no mapping layer pretending the database doesn&amp;rsquo;t exist. The payoff is predictability; the SQL you see is the SQL that runs. The cost is some of the magic: no &lt;code&gt;User.find(1).orders.where(total: 100..).first_or_create&lt;/code&gt; one-liners.&lt;/p&gt;
&lt;p&gt;For long-lived OLTP systems with non-trivial query shapes, that predictability is worth more than the magic. For short-lived CRUD apps, it isn&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="when-orms-still-earn-their-place"&gt;When ORMs still earn their place
&lt;/h2&gt;&lt;p&gt;ORMs have a place. It&amp;rsquo;s narrower than the industry&amp;rsquo;s default deployment suggests. The workloads where the velocity payoff consistently outweighs the coupling cost share two properties: they&amp;rsquo;re bounded in scope and they&amp;rsquo;re bounded in lifespan.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Short-lived prototypes and experiments.&lt;/strong&gt; Projects that will be rewritten, replaced, or discarded within a year. Model-first iteration is genuinely faster when the schema is fluid, and the coupling cost doesn&amp;rsquo;t compound if the project doesn&amp;rsquo;t live long enough to hit it.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CRUD-heavy internal tools and admin UIs.&lt;/strong&gt; Query shapes are uniform and simple, the workload won&amp;rsquo;t scale past the ORM&amp;rsquo;s comfort zone, and the system doesn&amp;rsquo;t outlive the product it supports. The ORM&amp;rsquo;s constraints function as a style guide rather than as a limit on what the application can do.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;That&amp;rsquo;s the list. Not &amp;ldquo;projects where the team knows Rails.&amp;rdquo; Not &amp;ldquo;workloads with uniform query shape, for now.&amp;rdquo; Not &amp;ldquo;small teams.&amp;rdquo; Those framings start as short-lived exceptions and end up as the default, and once the project outlives its original scope the coupling cost compounds silently until it&amp;rsquo;s too expensive to remove.&lt;/p&gt;
&lt;p&gt;The failure mode isn&amp;rsquo;t picking an ORM for a prototype. It&amp;rsquo;s keeping it ten years later, after the prototype has become the company&amp;rsquo;s main production system, after the workload has grown past its original shape, and after migrating off costs more than a rewrite of the application. Most of the ORM codebases engineers end up cursing started in one of the two bullets above and were never reconsidered when they outgrew them.&lt;/p&gt;
&lt;h2 id="trade-offs"&gt;Trade-offs
&lt;/h2&gt;&lt;p&gt;Everything in this post has a counter-argument, and the counter-arguments are real.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;ORMs save real time on simple queries.&lt;/strong&gt; &lt;code&gt;User.find(1)&lt;/code&gt; is shorter than &lt;code&gt;SELECT * FROM users WHERE id = 1&lt;/code&gt;. Across a codebase it adds up.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Type safety in the application layer.&lt;/strong&gt; Rails and ActiveRecord don&amp;rsquo;t give compile-time types, but Django&amp;rsquo;s model fields, SQLAlchemy&amp;rsquo;s typed columns, and Hibernate&amp;rsquo;s entity types do. Raw SQL&amp;rsquo;s answer is schema-first code generation (sqlc, jOOQ), which works but requires tooling.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Domain modeling.&lt;/strong&gt; Some teams legitimately want their data model to have methods, validations, and behavior co-located with the data. An ORM gives that for free; a query builder doesn&amp;rsquo;t.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Team familiarity.&lt;/strong&gt; A team that knows Rails deeply will out-ship a team learning sqlc for the same project. The right answer depends on the team, not the abstract merits.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The middle ground isn&amp;rsquo;t free.&lt;/strong&gt; Typed query builders require maintained type definitions. Schema-first code generation adds a build step. &amp;ldquo;No ORM&amp;rdquo; means a different abstraction, maintained by you.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The choice isn&amp;rsquo;t ideological. It&amp;rsquo;s a trade between two failure modes: the ORM&amp;rsquo;s coupling cost versus the query-builder&amp;rsquo;s boilerplate and maintenance cost. For short-lived systems, the ORM wins. For long-lived systems, the thinner layer wins. The catch is that most systems surviving their first year are long-lived, and most teams underestimate how long their system will live. If the project is still running three years from now, you&amp;rsquo;re probably in the second category whether or not you planned to be.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;The thing an ORM sells is a mapping between code and schema. The thing it delivers is a coupling. For short-lived projects (the prototype, the internal CRUD tool, the bounded experiment) the trade is worth it; the coupling cost is deferred, and by the time it would catch up the project has served its purpose or been replaced.&lt;/p&gt;
&lt;p&gt;For projects that live long enough and grow complex enough (which is almost any project that survives its first year) the coupling becomes the dominant cost. Every major framework upgrade is a migration of its own. Every scale inflection requires working around the ORM&amp;rsquo;s opinions. Every query past the CRUD ceiling is raw SQL anyway. The better default for an application the team expects to still be running in three years is schema-first: keep the DDL canonical, keep queries as first-class code-reviewed artifacts, use a thin typed layer (sqlc, jOOQ, Kysely, Drizzle) to bridge to the application, and leave the ORM in the toolbox for cases that genuinely match its narrow strengths.&lt;/p&gt;
&lt;p&gt;If you&amp;rsquo;re starting a project expected to live more than a year, default to schema-first. Inside an existing ORM codebase where the signals are showing up (raw-SQL ratio creeping up, migrations that require cross-team coordination, queries the ORM can&amp;rsquo;t express, performance paths that bypass it anyway) the useful question isn&amp;rsquo;t whether to migrate off. It&amp;rsquo;s where to draw the schema-first boundary for new work. Usually at new subsystems, not legacy code. Grandfather what&amp;rsquo;s there, pick up sqlc or jOOQ or Kysely for new code, and let the boundary move over years.&lt;/p&gt;</description></item><item><title>Schema Conventions Don't Survive Without Automation</title><link>https://explainanalyze.com/p/schema-conventions-dont-survive-without-automation/</link><pubDate>Sun, 06 Apr 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/schema-conventions-dont-survive-without-automation/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Schema Conventions Don't Survive Without Automation" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;Schema conventions only survive when automation enforces them. A rule a linter, ORM, migration runner, or IaC module checks will hold for years; a rule the team merely agreed to won&amp;rsquo;t outlast the people who agreed. Pick the conventions your automation needs and skip the purely subjective ones, because they&amp;rsquo;ll drift regardless of how strongly anyone feels.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;A new engineer adds a table to the analytics schema and runs into a build break: the CI lint rule complains about a missing soft-delete column. She checks ten other analytics tables. Eight have &lt;code&gt;deleted_at&lt;/code&gt;. Two have &lt;code&gt;is_deleted&lt;/code&gt;. One ignores soft-delete because its rows are immutable. She asks in #data-eng which convention applies and gets back &amp;ldquo;depends on the table, ask the original author.&amp;rdquo; Two of the original authors have left. She adds &lt;code&gt;deleted_at TIMESTAMP NULL&lt;/code&gt; to match the majority, ships the PR, and the dashboard that aggregates across all eleven tables starts double-counting the rows where &lt;code&gt;is_deleted = 1&lt;/code&gt; overlaps with the new &lt;code&gt;deleted_at IS NULL&lt;/code&gt;.&lt;/p&gt;
&lt;p&gt;The convention the lint rule was meant to enforce never actually existed. The &lt;code&gt;deleted_at&lt;/code&gt; pattern landed in 2017; &lt;code&gt;is_deleted&lt;/code&gt; in 2019 from an engineer who preferred the explicit boolean; the no-soft-delete table in 2021 from a third engineer who argued (correctly, for that table) that soft delete didn&amp;rsquo;t fit the use case. Each decision was right in isolation. None of them got written down anywhere a tool could read. The lint rule enforces the column name. It cannot tell that the column name is meaningless when three patterns coexist for the same operation.&lt;/p&gt;
&lt;p&gt;The corollary is the thesis of this post. Conventions only survive when a tool is enforcing them, and they only matter when the tool checks what they actually mean, not just what they&amp;rsquo;re called. Pick the ones a linter, ORM, or migration runner can check fully (column name, behavior, and consistency against the surrounding schema), enforce them in CI, and skip the ones the tool can only validate by name. Those drift the same way the team&amp;rsquo;s memory does.&lt;/p&gt;
&lt;h2 id="what-conventions-means-here"&gt;What &amp;ldquo;conventions&amp;rdquo; means here
&lt;/h2&gt;&lt;p&gt;Conventions in this post means the decisions that apply across every table, not the design of any particular table:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Naming.&lt;/strong&gt; &lt;code&gt;snake_case&lt;/code&gt;, &lt;code&gt;camelCase&lt;/code&gt;, or &lt;code&gt;ALLCAPS&lt;/code&gt; for tables and columns.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Table names.&lt;/strong&gt; Singular (&lt;code&gt;user&lt;/code&gt;) or plural (&lt;code&gt;users&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Primary keys.&lt;/strong&gt; Bare &lt;code&gt;id&lt;/code&gt; or &lt;code&gt;&amp;lt;table&amp;gt;_id&lt;/code&gt;. BIGINT, UUID, or composite.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Foreign keys.&lt;/strong&gt; &lt;code&gt;user_id&lt;/code&gt; referencing &lt;code&gt;users.id&lt;/code&gt;, or ad-hoc names like &lt;code&gt;owner&lt;/code&gt; and &lt;code&gt;creator&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mandatory columns.&lt;/strong&gt; &lt;code&gt;created_at&lt;/code&gt;, &lt;code&gt;updated_at&lt;/code&gt;, &lt;code&gt;deleted_at&lt;/code&gt;, &lt;code&gt;created_by&lt;/code&gt;. Which tables need them and which don&amp;rsquo;t.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Status and enum patterns.&lt;/strong&gt; INT with documented values, CHECK constraint, or native ENUM. Zero-indexed or one-indexed.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Boolean naming.&lt;/strong&gt; &lt;code&gt;is_active&lt;/code&gt;, &lt;code&gt;has_completed&lt;/code&gt;, &lt;code&gt;can_edit&lt;/code&gt;, or bare &lt;code&gt;active&lt;/code&gt; / &lt;code&gt;completed&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Timestamp types.&lt;/strong&gt; &lt;code&gt;TIMESTAMP&lt;/code&gt;, &lt;code&gt;DATETIME&lt;/code&gt;, &lt;code&gt;TIMESTAMPTZ&lt;/code&gt;. Timezone-aware or naive.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Character sets and collations.&lt;/strong&gt; &lt;code&gt;utf8mb4&lt;/code&gt; vs &lt;code&gt;latin1&lt;/code&gt;; &lt;code&gt;en_US.UTF-8&lt;/code&gt; vs &lt;code&gt;C&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;None of these have one right answer. All of them have consequences that multiply across the lifetime of the schema.&lt;/p&gt;
&lt;h2 id="humans-benefit-but-not-durably"&gt;Humans benefit, but not durably
&lt;/h2&gt;&lt;p&gt;Consistent schemas are easier for humans. Onboarding is faster, review is mechanical, queries are predictable. These benefits are real. They&amp;rsquo;re also entirely dependent on something other than memory holding the convention in place.&lt;/p&gt;
&lt;p&gt;A new engineer spends less time building a mental model when PKs, FKs, and timestamps are named the same way everywhere. True, and the convention enabling it exists only as long as someone is actively keeping it enforced.&lt;/p&gt;
&lt;p&gt;A migration adding &lt;code&gt;CustomerReference INT&lt;/code&gt; in a codebase where everything else is &lt;code&gt;customer_id BIGINT&lt;/code&gt; gets flagged when conventions are consistent. True, and whether it actually gets flagged depends on whether the reviewer remembers the rule or a linter is enforcing it.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;JOIN users ON orders.user_id = users.id&lt;/code&gt; works without a lookup when the convention is &lt;code&gt;&amp;lt;table&amp;gt;_id&lt;/code&gt;. True, and the query is right only because every prior migration followed the rule, which is only the case if something kept them on track.&lt;/p&gt;
&lt;p&gt;The pattern: every human benefit is downstream of enforcement. A rule that exists only because the current team agreed to it lasts exactly as long as that team does. People change jobs, preferences evolve, new hires bring their own instincts. Within a few quarters of turnover, a human-only convention is gone, and so is the benefit.&lt;/p&gt;
&lt;p&gt;The reasons worth picking a convention are the reasons a machine can enforce it.&lt;/p&gt;
&lt;h2 id="why-it-matters-for-automation"&gt;Why it matters for automation
&lt;/h2&gt;&lt;p&gt;Automation is the only thing that holds a convention over time. A linter fails the build when &lt;code&gt;snake_case&lt;/code&gt; becomes &lt;code&gt;camelCase&lt;/code&gt; and keeps failing until someone addresses it; a team agreement doesn&amp;rsquo;t. The tools below are both the enforcement mechanisms and, by that logic, the only reasons a convention is worth picking in the first place. If none of them apply to your stack, the convention probably isn&amp;rsquo;t worth the debate.&lt;/p&gt;
&lt;p&gt;Every tool that touches the schema reads conventions implicitly. When conventions are consistent, the tool works without configuration. When they&amp;rsquo;re not, someone has to tell the tool how to handle each exception. Usually in a config file nobody maintains.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ORMs rely on naming rules.&lt;/strong&gt; ActiveRecord assumes a table named &lt;code&gt;users&lt;/code&gt; has a primary key &lt;code&gt;id&lt;/code&gt; and that a &lt;code&gt;user_id&lt;/code&gt; column is the foreign key. Deviate and you write explicit mappings. Every non-standard table adds a line of configuration; every &lt;code&gt;belongs_to :author, foreign_key: :creator_ref&lt;/code&gt; is convention drift showing up as code. Other ORMs are more explicit but still benefit from predictable column names: autogeneration works, inference works, magic methods work.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Code generators produce better output.&lt;/strong&gt; &lt;a class="link" href="https://sqlc.dev/" target="_blank" rel="noopener"
 &gt;sqlc&lt;/a&gt;, Prisma, jOOQ, and similar tools read schema metadata and emit type-safe client code. Consistent naming means the generated output looks like hand-written code. Inconsistent naming produces &lt;code&gt;getCustomerReferenceByUserId()&lt;/code&gt; sitting next to &lt;code&gt;getOrderByUserId()&lt;/code&gt;, same concept, different shape, every caller has to remember the difference.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Migration tools depend on mandatory columns.&lt;/strong&gt; Frameworks that manage &lt;code&gt;created_at&lt;/code&gt; / &lt;code&gt;updated_at&lt;/code&gt; automatically assume every table has them. Tables that omit these columns silently break the assumption: inserts work, updates work, but the &amp;ldquo;last modified&amp;rdquo; display in an admin UI shows &lt;code&gt;null&lt;/code&gt; for some tables and not others.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Deployment pipelines assume a consistent migration shape.&lt;/strong&gt; Migration runners that execute schema changes as part of CI/CD (Flyway, Liquibase, Alembic, Atlas, skeema) rely on migration files following a predictable naming and ordering convention, up/down scripts that mirror each other, and tables that don&amp;rsquo;t need per-case special-handling. Zero-downtime patterns like expand-and-contract assume &lt;code&gt;updated_at&lt;/code&gt; exists for cache invalidation, that new columns are nullable or have defaults so old and new application versions can both write the table, and that soft-delete markers are consistent so rolling deploys across mixed versions don&amp;rsquo;t resurrect rows one version thought were gone. Every convention that drifts turns a deploy playbook into a per-table checklist, and the checklists are what get skipped under time pressure.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Schema diffing and drift detection depend on consistent shape.&lt;/strong&gt; Tools like &lt;a class="link" href="https://atlasgo.io/" target="_blank" rel="noopener"
 &gt;Atlas&lt;/a&gt; and &lt;a class="link" href="https://www.skeema.io/" target="_blank" rel="noopener"
 &gt;skeema&lt;/a&gt; compare the desired schema (in version control) to the actual state of each environment and generate the migration to reconcile them. They work well when naming, types, and mandatory columns are uniform, and produce noisy diffs, false positives, and hand-maintained exception lists when they aren&amp;rsquo;t. Environment parity between dev, staging, and prod degrades the same way: the drift the team never notices becomes the one that breaks a deploy at the worst time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Schema linters only work if there&amp;rsquo;s a rule to check.&lt;/strong&gt; &lt;a class="link" href="https://sqlfluff.com/" target="_blank" rel="noopener"
 &gt;SQLFluff&lt;/a&gt;, &lt;a class="link" href="https://github.com/quarylabs/sqruff" target="_blank" rel="noopener"
 &gt;sqruff&lt;/a&gt;, and similar tools can enforce naming conventions, require certain columns on new tables, reject forbidden types, and flag style issues. But the lint rule has to match the team&amp;rsquo;s convention. No convention, no rule. No rule, no enforcement.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Documentation generators&lt;/strong&gt; like &lt;a class="link" href="https://github.com/k1LoW/tbls" target="_blank" rel="noopener"
 &gt;tbls&lt;/a&gt; and &lt;a class="link" href="https://schemaspy.org/" target="_blank" rel="noopener"
 &gt;SchemaSpy&lt;/a&gt; produce browsable schema docs straight from the catalog. Consistent conventions make the generated output navigable. Inconsistent ones make it look like a dump.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Schema-reading LLMs and RAG pipelines&lt;/strong&gt; have joined the same list. Copilot, MCP-backed agents, text-to-SQL tools, and retrieval-augmented coding systems pull column names and types from &lt;code&gt;information_schema&lt;/code&gt; and pattern-match them against natural-language questions. When one table uses &lt;code&gt;createdAt&lt;/code&gt;, another uses &lt;code&gt;created_date&lt;/code&gt;, and a third uses &lt;code&gt;date_created&lt;/code&gt;, the model either generalizes from the most-frequent variant and gets the other two wrong, or hedges and produces verbose conditional SQL. Uniform naming lets the model carry an assumption across tables without re-checking the catalog for every column; the accuracy gains from clean conventions stack on top of the 27% lift studies attribute to column comments alone. Conventions that were about making humans and codegen tools agree turn out to matter just as much for the machine-reading layer.&lt;/p&gt;
&lt;p&gt;The common thread: tools treat conventions as a contract. When the contract holds, tools work. When it doesn&amp;rsquo;t, tools either break or force the team to maintain exceptions forever.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;The contract is implicit&lt;/strong&gt;
 &lt;div&gt;Nobody writes down that &lt;code&gt;created_at&lt;/code&gt; must be a &lt;code&gt;TIMESTAMPTZ&lt;/code&gt; or that FKs must be named &lt;code&gt;&amp;lt;table&amp;gt;_id&lt;/code&gt;; the tooling silently starts expecting it. The moment a table violates the expectation, every tool built on it starts producing surprises. Conventions are a contract whether or not anyone acknowledges them, and the tools are the ones keeping score.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="the-menu-pick-what-automation-expects"&gt;The menu: pick what automation expects
&lt;/h2&gt;&lt;p&gt;Each decision below matters only if something in your stack cares about it. The notes below lean on what tools typically expect. Pick the option that matches your automation. If nothing in your stack cares either way, skip the decision; it won&amp;rsquo;t survive the next round of team change regardless of which side &amp;ldquo;won&amp;rdquo; the debate.&lt;/p&gt;
&lt;h3 id="naming-snake-vs-camel"&gt;Naming: snake vs camel
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;snake_case&lt;/code&gt; is the idiomatic choice for PostgreSQL and MySQL. Unquoted identifiers in PostgreSQL are case-folded to lowercase, so &lt;code&gt;created_at&lt;/code&gt; and &lt;code&gt;createdAt&lt;/code&gt; both become &lt;code&gt;createdat&lt;/code&gt; unless one is quoted, which means mixed-case names force every query to quote the column. &lt;code&gt;camelCase&lt;/code&gt; works if the team is disciplined about quoting, but most teams aren&amp;rsquo;t. Pick &lt;code&gt;snake_case&lt;/code&gt; unless there&amp;rsquo;s a specific reason not to.&lt;/p&gt;
&lt;h3 id="table-names-singular-or-plural"&gt;Table names: singular or plural
&lt;/h3&gt;&lt;p&gt;Both work. Rails and Django default to plural (&lt;code&gt;users&lt;/code&gt;). &lt;code&gt;CREATE TABLE user&lt;/code&gt; will actually fail in PostgreSQL because &lt;code&gt;user&lt;/code&gt; is a reserved word, which is an argument for plural. Singular reads cleaner in joins (&lt;code&gt;user.id&lt;/code&gt; feels like &amp;ldquo;the user&amp;rsquo;s id&amp;rdquo;). This is the smallest decision on the list in terms of consequences. The real requirement is that whatever you pick, you use it everywhere.&lt;/p&gt;
&lt;h3 id="primary-keys-id-vs-table_id"&gt;Primary keys: &lt;code&gt;id&lt;/code&gt; vs &lt;code&gt;&amp;lt;table&amp;gt;_id&lt;/code&gt;
&lt;/h3&gt;&lt;p&gt;Bare &lt;code&gt;id&lt;/code&gt; is shorter and matches the default of most ORMs. It also creates a subtle hazard: &lt;code&gt;table_a.id = table_b.id&lt;/code&gt; is syntactically valid SQL that silently returns wrong results. &lt;code&gt;&amp;lt;table&amp;gt;_id&lt;/code&gt; (so &lt;code&gt;user_id&lt;/code&gt; on the &lt;code&gt;users&lt;/code&gt; table) makes cross-table joins impossible to write accidentally, because the identifier tells you which table the ID belongs to.&lt;/p&gt;
&lt;p&gt;The trade-off is that ORM defaults expect &lt;code&gt;id&lt;/code&gt;, so using &lt;code&gt;&amp;lt;table&amp;gt;_id&lt;/code&gt; means configuring every model. For teams that rely heavily on an ORM&amp;rsquo;s conventions, staying with &lt;code&gt;id&lt;/code&gt; is pragmatic. For teams with more ad-hoc SQL, &lt;code&gt;&amp;lt;table&amp;gt;_id&lt;/code&gt; pays off.&lt;/p&gt;
&lt;h3 id="foreign-key-naming"&gt;Foreign key naming
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;user_id&lt;/code&gt; referencing &lt;code&gt;users.id&lt;/code&gt; is the convention most tools expect. Ad-hoc names like &lt;code&gt;owner&lt;/code&gt;, &lt;code&gt;creator&lt;/code&gt;, &lt;code&gt;assigned_to&lt;/code&gt;, &lt;code&gt;ref_id&lt;/code&gt; are sometimes necessary (multiple FKs to the same table need different names) but should be explicit about what they reference, either in the column name (&lt;code&gt;owner_user_id&lt;/code&gt;) or in a schema comment. A column named &lt;code&gt;owner&lt;/code&gt; with no comment and no FK is a question nobody can answer from the schema alone.&lt;/p&gt;
&lt;h3 id="mandatory-columns"&gt;Mandatory columns
&lt;/h3&gt;&lt;p&gt;Decide which columns every table must have. Common choices:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()&lt;/code&gt;. Row creation time.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()&lt;/code&gt;. Last modification, driven by a trigger or application logic.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;created_by&lt;/code&gt; / &lt;code&gt;updated_by&lt;/code&gt;. Audit fields, if the team needs them.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;deleted_at TIMESTAMPTZ&lt;/code&gt;. Soft-delete marker.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Partial adoption is worse than none&lt;/strong&gt;
 &lt;div&gt;If 80% of tables have &lt;code&gt;deleted_at&lt;/code&gt; and 20% don&amp;rsquo;t, every query has to remember which tables to filter and which not to. The queries that forget silently return soft-deleted rows from some tables and not others. Pick a rule (&amp;ldquo;every table has &lt;code&gt;created_at&lt;/code&gt;, &lt;code&gt;updated_at&lt;/code&gt;; soft-delete tables have &lt;code&gt;deleted_at&lt;/code&gt;&amp;rdquo;) and apply it uniformly.&lt;/div&gt;
&lt;/div&gt;

&lt;h3 id="status-and-enum-patterns"&gt;Status and enum patterns
&lt;/h3&gt;&lt;p&gt;Three common strategies, each with trade-offs:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;INT with documented values.&lt;/strong&gt; &lt;code&gt;status TINYINT NOT NULL COMMENT '1=active, 2=paused, 3=cancelled'&lt;/code&gt;. Compact, fast, relies on comments for semantics. Works across engines.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;CHECK constraint.&lt;/strong&gt; &lt;code&gt;status VARCHAR(20) CHECK (status IN ('active', 'paused', 'cancelled'))&lt;/code&gt;. Self-documenting in the DDL, slightly larger storage, human-readable in query results.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Native ENUM.&lt;/strong&gt; PostgreSQL has first-class ENUM types, MySQL has &lt;code&gt;ENUM(...)&lt;/code&gt;. Compact and typed, but changing the set requires a schema migration; in PostgreSQL, removing a value is genuinely hard.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Any of these is fine. Mixing them (one table uses INT, another uses CHECK, a third uses ENUM) is what creates the problem. Every query that aggregates across tables has to handle three value formats.&lt;/p&gt;
&lt;h3 id="boolean-prefixes"&gt;Boolean prefixes
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;is_active&lt;/code&gt;, &lt;code&gt;has_completed&lt;/code&gt;, &lt;code&gt;can_edit&lt;/code&gt; make filter expressions self-documenting: &lt;code&gt;WHERE is_active AND NOT is_deleted&lt;/code&gt;. Bare names like &lt;code&gt;active&lt;/code&gt; or &lt;code&gt;completed&lt;/code&gt; create ambiguity in review. Is this column a flag or a timestamp? Adjective or verb? Prefixing eliminates the ambiguity at no runtime cost.&lt;/p&gt;
&lt;h3 id="timestamp-types"&gt;Timestamp types
&lt;/h3&gt;&lt;p&gt;The choice matters more than the name. &lt;code&gt;TIMESTAMP&lt;/code&gt; in MySQL auto-converts between UTC and the session timezone, which is usually not what you want. &lt;code&gt;DATETIME&lt;/code&gt; stores the literal value with no timezone awareness. PostgreSQL&amp;rsquo;s &lt;code&gt;TIMESTAMPTZ&lt;/code&gt; stores UTC with automatic conversion on input and output, the most forgiving option for most applications.&lt;/p&gt;
&lt;p&gt;Mixing types across related tables is where silent timezone bugs come from. A &lt;code&gt;created_at TIMESTAMPTZ&lt;/code&gt; on one table joined to a &lt;code&gt;DATETIME&lt;/code&gt; on another will either implicit-cast or mismatch, depending on engine and version. Pick one per engine and apply it everywhere.&lt;/p&gt;
&lt;h3 id="character-sets-and-collations"&gt;Character sets and collations
&lt;/h3&gt;&lt;p&gt;&lt;code&gt;utf8mb4&lt;/code&gt; in MySQL, &lt;code&gt;UTF-8&lt;/code&gt; in PostgreSQL. Anything else in 2026 is a legacy holdover. The subtle hazard: mixing charsets across columns causes joins between text columns to fail silently or return wrong results. PostgreSQL is stricter about this; MySQL is more permissive and more dangerous because of it.&lt;/p&gt;
&lt;h2 id="conventions-beyond-the-schema"&gt;Conventions beyond the schema
&lt;/h2&gt;&lt;p&gt;Schema conventions usually stop at the DDL, but the automation layer around the database depends on naming decisions that live outside it: secrets, endpoints, users, roles, hostnames, backup files, environment variables. Those names show up in Terraform modules, Vault paths, Kubernetes resources, IAM policies, service-discovery records, monitoring dashboards, and every deploy pipeline. When they&amp;rsquo;re consistent, the infrastructure is self-describing and IaC modules stay generic. When they aren&amp;rsquo;t, every piece of automation grows a special case.&lt;/p&gt;
&lt;p&gt;Common places this shows up:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Secret names.&lt;/strong&gt; &lt;code&gt;prod/db/orders/primary/password&lt;/code&gt; vs &lt;code&gt;prod-orders-db-pw&lt;/code&gt; vs &lt;code&gt;orders_prod_password&lt;/code&gt;. A clear prefix/suffix pattern lets secret rotation scripts, IAM scopes (&lt;code&gt;arn:aws:secretsmanager:*:*:secret:prod/db/*&lt;/code&gt;), and environment-promotion automation use wildcards instead of hardcoded lists.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Hostnames and endpoints.&lt;/strong&gt; &lt;code&gt;db-orders-rw.internal&lt;/code&gt; and &lt;code&gt;db-orders-ro.internal&lt;/code&gt; for reader/writer splits, &lt;code&gt;db-orders-primary-0.us-east-1&lt;/code&gt; for cluster node addressing. Consistent patterns mean DR runbooks, connection pools, and failover scripts can resolve endpoints by transforming a base name rather than reading from config.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Database users and roles.&lt;/strong&gt; &lt;code&gt;app_orders_rw&lt;/code&gt;, &lt;code&gt;app_orders_ro&lt;/code&gt;, &lt;code&gt;migration_bot&lt;/code&gt;, &lt;code&gt;readonly_analytics&lt;/code&gt;. The role name should say what it can do. Teams without a convention end up with &lt;code&gt;svc_user_42&lt;/code&gt;, &lt;code&gt;rails&lt;/code&gt;, &lt;code&gt;monitoring&lt;/code&gt;, and nobody can audit privileges without a spreadsheet.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Database names.&lt;/strong&gt; &lt;code&gt;orders_prod&lt;/code&gt; vs &lt;code&gt;prod_orders&lt;/code&gt; vs &lt;code&gt;orders-production&lt;/code&gt;. Consistent environment placement (always suffix or always prefix) means wildcard grants, backup pattern matching, and cross-environment queries stay simple.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Environment variables.&lt;/strong&gt; &lt;code&gt;DB_ORDERS_HOST&lt;/code&gt;, &lt;code&gt;DB_ORDERS_USER&lt;/code&gt;, &lt;code&gt;DB_ORDERS_PASSWORD_SECRET&lt;/code&gt;. A per-service naming convention lets config loaders and IaC modules generate the full variable set from a single identifier.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Backup and snapshot names.&lt;/strong&gt; &lt;code&gt;orders-prod-20260420-0000&lt;/code&gt; vs &lt;code&gt;backup_orders_20260420&lt;/code&gt;. Retention jobs, restore runbooks, and compliance audits all read these names by pattern.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These aren&amp;rsquo;t schema conventions in the strict sense; they&amp;rsquo;re operational conventions that happen to be tied to the schema. They follow the same rules: pick a pattern, apply it everywhere, document it where the infrastructure code lives, and enforce it in the IaC linter (&lt;code&gt;tflint&lt;/code&gt;, &lt;code&gt;checkov&lt;/code&gt;) or the Kubernetes admission controller so new resources can&amp;rsquo;t be named off-pattern.&lt;/p&gt;
&lt;p&gt;The failure mode is the same as inside the schema. A team with three secret-naming patterns needs a custom script per resource. A team with three hostname patterns runs DR runbooks twice as long as they should be. Operational conventions have the same compounding cost as schema conventions, in a different layer; the tooling to enforce them is different (IaC linters instead of SQLFluff), but the discipline is identical.&lt;/p&gt;
&lt;h2 id="enforcement-conventions-without-enforcement-decay"&gt;Enforcement: conventions without enforcement decay
&lt;/h2&gt;&lt;p&gt;Written conventions that nobody enforces last until the next person who didn&amp;rsquo;t read the doc. The only conventions that hold over years are the ones CI checks.&lt;/p&gt;
&lt;h3 id="schema-linters"&gt;Schema linters
&lt;/h3&gt;&lt;p&gt;&lt;a class="link" href="https://sqlfluff.com/" target="_blank" rel="noopener"
 &gt;SQLFluff&lt;/a&gt; is the most popular for PostgreSQL and MySQL. It runs on migration files in CI and can enforce:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Naming rules (&lt;code&gt;snake_case&lt;/code&gt; only, specific prefixes/suffixes).&lt;/li&gt;
&lt;li&gt;Required columns on &lt;code&gt;CREATE TABLE&lt;/code&gt; (every table must have &lt;code&gt;created_at&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Forbidden types (reject &lt;code&gt;TIMESTAMP&lt;/code&gt; in favor of &lt;code&gt;TIMESTAMPTZ&lt;/code&gt;).&lt;/li&gt;
&lt;li&gt;Style (trailing commas, keyword casing, indentation).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The alternative is a custom linter, a script that parses migration files and checks them against a ruleset. More work to build but more flexible if the rules are unusual. Teams with strong opinions often end up here.&lt;/p&gt;
&lt;h3 id="ci-checks-on-the-schema-itself"&gt;CI checks on the schema itself
&lt;/h3&gt;&lt;p&gt;Beyond linting migration files, a CI job can introspect the database after migrations are applied and assert properties of the final schema:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Every table in the application schema has created_at
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &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="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;table_schema&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;public&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;HAVING&lt;/span&gt;&lt;span class="w"&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="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;FILTER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;created_at&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&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&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;If the result is non-empty, fail the build. This catches the migration that adds a new table without the mandatory columns, the case a file-level linter can miss if the &lt;code&gt;CREATE TABLE&lt;/code&gt; was split across migrations.&lt;/p&gt;
&lt;p&gt;Other useful assertions:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- No table uses TIMESTAMP without timezone
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;table_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;column_name&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &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="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;data_type&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;timestamp without time zone&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;table_schema&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;public&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Every FK column has an index
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- (expensive to query but worth running on schedule)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Introspection-based checks run against the shape of the schema after migrations are applied; they catch drift the file-level linter can&amp;rsquo;t see.&lt;/p&gt;
&lt;h3 id="pre-commit-hooks"&gt;Pre-commit hooks
&lt;/h3&gt;&lt;p&gt;Developer-machine enforcement: running &lt;code&gt;sqlfluff&lt;/code&gt; on staged migration files before commit. Faster feedback than CI, but only works if every developer has the hook installed. Treat pre-commit hooks as a developer experience improvement, not as the real gate. CI is the gate.&lt;/p&gt;
&lt;h3 id="codeowners-on-migration-directories"&gt;CODEOWNERS on migration directories
&lt;/h3&gt;&lt;p&gt;Putting a small group of owners on &lt;code&gt;migrations/&lt;/code&gt; forces review by someone who understands the conventions. This is a human check, not a mechanical one, but it catches things the linter can&amp;rsquo;t (&amp;ldquo;this new table has all the right columns but the design is wrong&amp;rdquo;). The owner doesn&amp;rsquo;t have to be one person; a rotating review responsibility works.&lt;/p&gt;
&lt;h3 id="review-templates"&gt;Review templates
&lt;/h3&gt;&lt;p&gt;A PR template that includes a checklist for schema changes (&amp;ldquo;does this follow the naming convention? does it include mandatory columns? are the types consistent with existing tables?&amp;rdquo;) nudges the author to check before review. The cost is zero; the benefit is that most issues get caught before they reach a reviewer.&lt;/p&gt;
&lt;h3 id="scope-strict-for-new-lenient-for-legacy"&gt;Scope: strict for new, lenient for legacy
&lt;/h3&gt;&lt;p&gt;The enforcement question that derails most teams: do existing tables have to meet the convention? Trying to retrofit decades of legacy is an impossible project; requiring only new tables to meet the convention is achievable. The practical pattern:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;New tables.&lt;/strong&gt; Linter is strict. No exceptions without a documented reason.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Existing tables.&lt;/strong&gt; Grandfathered. Linter skips them or only checks newly-added columns.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Legacy migrations.&lt;/strong&gt; An explicit backlog, prioritized by frequency of use and onboarding pain.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This splits the problem into &amp;ldquo;hold the line on new work&amp;rdquo; and &amp;ldquo;improve legacy opportunistically.&amp;rdquo; Both are manageable. Trying to do both at once isn&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="the-hardest-part-changing-conventions-without-creating-a-new-one"&gt;The hardest part: changing conventions without creating a new one
&lt;/h2&gt;&lt;p&gt;Conventions decay not because they were bad, but because they changed faster than the team could propagate the change. The result isn&amp;rsquo;t &amp;ldquo;the new convention&amp;rdquo;. It&amp;rsquo;s a schema with three coexisting conventions, none of which applies everywhere.&lt;/p&gt;
&lt;p&gt;The discipline is straightforward, even if it&amp;rsquo;s not always followed.&lt;/p&gt;
&lt;h3 id="write-the-convention-down"&gt;Write the convention down
&lt;/h3&gt;&lt;p&gt;Before enforcement, before any migration, there has to be a single authoritative document: a &lt;code&gt;SCHEMA-CONVENTIONS.md&lt;/code&gt; in the repo, or a runbook, or an RFC. Not a Slack thread, not tribal knowledge. Something a new engineer can read and apply.&lt;/p&gt;
&lt;p&gt;The doc is short by design: a page or two, not a book. It answers &amp;ldquo;what naming convention do we use?&amp;rdquo; and &amp;ldquo;what columns does every table need?&amp;rdquo; and &amp;ldquo;which timestamp type?&amp;rdquo;. It doesn&amp;rsquo;t try to teach relational design. Short docs get read; long ones don&amp;rsquo;t.&lt;/p&gt;
&lt;h3 id="use-a-lightweight-rfc-process-for-changes"&gt;Use a lightweight RFC process for changes
&lt;/h3&gt;&lt;p&gt;When someone wants to change a convention (switch from &lt;code&gt;id&lt;/code&gt; to &lt;code&gt;&amp;lt;table&amp;gt;_id&lt;/code&gt;, add &lt;code&gt;updated_by&lt;/code&gt; as a mandatory column, move from INT to UUID primary keys) it goes through a written proposal:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;What&amp;rsquo;s changing and why.&lt;/li&gt;
&lt;li&gt;Impact on existing tables (migrate all, grandfather, or cutover by date).&lt;/li&gt;
&lt;li&gt;Impact on tools, ORMs, dashboards, and downstream consumers.&lt;/li&gt;
&lt;li&gt;Who decides (single decision-maker or review board).&lt;/li&gt;
&lt;li&gt;Explicit cutover date if changing for new work only.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The RFC doesn&amp;rsquo;t have to be heavyweight. A paragraph in a shared doc, reviewed by two or three people, approved by a named owner. The value isn&amp;rsquo;t the document. It&amp;rsquo;s the forcing function that prevents conventions from changing by PR comment.&lt;/p&gt;
&lt;h3 id="decide-migrate-grandfather-or-both"&gt;Decide: migrate, grandfather, or both
&lt;/h3&gt;&lt;p&gt;Three options, each with a different risk profile:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Migrate everything.&lt;/strong&gt; Rename columns across the schema, update every query, every ORM model, every dashboard. This is the clean option and almost never the practical one. Retroactive renaming breaks downstream consumers the team may not even know exist: analytics jobs, exports, integration partners, cached query plans.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Grandfather legacy, enforce on new.&lt;/strong&gt; Old tables stay as-is; new tables follow the new rule. The schema ends up with two conventions coexisting, but it&amp;rsquo;s predictable: &amp;ldquo;tables before this date use X, tables after use Y.&amp;rdquo;&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Cutover with a migration window.&lt;/strong&gt; Pick a date, migrate the highest-traffic or highest-visibility tables before the date, grandfather the rest, close out the long tail opportunistically.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The grandfather option is the most common in practice because it respects the reality that the schema is a shared resource nobody fully owns. Write the decision down (&amp;ldquo;before 2025-Q3, tables used camelCase; after, snake_case&amp;rdquo;) so future engineers know the split exists and isn&amp;rsquo;t a bug.&lt;/p&gt;
&lt;h3 id="the-two-generation-rule"&gt;The two-generation rule
&lt;/h3&gt;&lt;div class="note-box"&gt;
 &lt;strong&gt;Two is the limit&lt;/strong&gt;
 &lt;div&gt;One convention is best. Two coexisting conventions is survivable - new engineers can be told &amp;ldquo;look at the table&amp;rsquo;s creation date.&amp;rdquo; Three or more is where schemas become unreviewable. Any proposal to change a convention needs to answer: &amp;ldquo;are we ending up with two generations, or a third?&amp;rdquo; A third generation is a forcing function to finish migrating the first one first, not to introduce a new one.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;This is a heuristic, not a hard rule, but it&amp;rsquo;s a useful test. When a proposed change would create a third convention without a plan to eliminate one of the existing two, the change probably isn&amp;rsquo;t worth it.&lt;/p&gt;
&lt;h2 id="when-to-accept-legacy-drift"&gt;When to accept legacy drift
&lt;/h2&gt;&lt;p&gt;Not every legacy convention is worth fixing. The calculation:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;How often does the old convention cause bugs?&lt;/strong&gt; Column names nobody can remember, types that force implicit casts, missing mandatory columns that break tooling. Real costs, worth migrating.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How often is the table touched?&lt;/strong&gt; A table used by ten queries a day is different from one used by ten thousand. Migration risk scales with usage.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;What breaks downstream?&lt;/strong&gt; ORM models, dashboards, exports, cached plans, monitoring. Every consumer of the table name or column name has to update. If the count is unknown, it&amp;rsquo;s higher than you think.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Is there a cheap alternative?&lt;/strong&gt; A &lt;code&gt;VIEW&lt;/code&gt; that exposes the table under the new convention, while the underlying table keeps its legacy name, can bridge the gap without a full migration.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The honest answer is often &amp;ldquo;leave it alone and document why.&amp;rdquo; A comment in the schema, or a note in the conventions doc, is cheaper than a migration and accomplishes the main goal: making the inconsistency visible and intentional.&lt;/p&gt;
&lt;h2 id="trade-offs"&gt;Trade-offs
&lt;/h2&gt;&lt;p&gt;Conventions have a cost. A rule that doesn&amp;rsquo;t serve automation is noise. It takes space in the conventions doc, invites bikeshedding in review, and adds nothing to the schema&amp;rsquo;s consistency over time, because there&amp;rsquo;s nothing to keep it from decaying the moment the people who cared move on. The heuristic: if no tool fails when the rule is violated, the rule doesn&amp;rsquo;t need to exist.&lt;/p&gt;
&lt;p&gt;Over-specifying is the second failure mode. A team with thirty linter rules will find a way around them or ignore them. Rules that block common, legitimate cases get bypassed with &lt;code&gt;-- noqa&lt;/code&gt; comments until the linter stops being a gate.&lt;/p&gt;
&lt;p&gt;The lightweight approach:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A small set of rules, each one tied to a specific tool that cares (naming, mandatory columns, forbidden types).&lt;/li&gt;
&lt;li&gt;A larger set of advisory warnings, not blockers.&lt;/li&gt;
&lt;li&gt;A clear escape hatch for exceptions, with the exception documented.&lt;/li&gt;
&lt;li&gt;Periodic review. Rules that fire too often are wrong; rules that never fire are noise.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Strict conventions are a feature up to the point where the enforcement matches the rule count. Beyond that, they become a tax on every change. The right level is the smallest set automation will actually enforce without constant arguments.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;The useful question is what your automation needs, and whether a machine can enforce it. If yes, pick the convention your automation needs and wire it into CI. If no, skip the decision; debating aesthetics in the absence of enforcement produces nothing that will still be true a year from now. People change, teams turn over, preferences drift. A convention enforced by a linter doesn&amp;rsquo;t care who wrote the migration; a convention enforced by &amp;ldquo;we agreed last quarter&amp;rdquo; does.&lt;/p&gt;
&lt;p&gt;The schemas that age well are the ones where the only surviving conventions are ones a linter, ORM, migration runner, or IaC module is actively enforcing. Everything else (bikeshed questions about singular vs. plural, religious debates about column ordering) drifts the moment the people who cared stop working there. That&amp;rsquo;s the predictable result of anchoring a rule to something as ephemeral as a team&amp;rsquo;s current preference.&lt;/p&gt;</description></item><item><title>Where Business Logic Lives - Database vs. Application</title><link>https://explainanalyze.com/p/where-business-logic-lives-database-vs.-application/</link><pubDate>Wed, 19 Mar 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/where-business-logic-lives-database-vs.-application/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Where Business Logic Lives - Database vs. Application" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;Keep the database narrow: &lt;code&gt;NOT NULL&lt;/code&gt;, &lt;code&gt;UNIQUE&lt;/code&gt;, &lt;code&gt;FK&lt;/code&gt; within a service, simple &lt;code&gt;CHECK&lt;/code&gt; for per-row invariants, generated columns for stable derived values. Put everything else (orchestration, computation, rules that change weekly, anything crossing services) in an application-layer library every writer uses. &amp;ldquo;Dumb database&amp;rdquo; is half right: dumb across service boundaries, narrowly smart within one.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;&lt;code&gt;amount &amp;gt;= 0&lt;/code&gt; lives in three places. A &lt;code&gt;CHECK&lt;/code&gt; on the column, a Pydantic validator in the API model, a guard in the order-creation service. Added in different quarters by different teams. Out of sync since GDPR forced a change to the validator that nobody propagated to the constraint. The migration tightening the &lt;code&gt;CHECK&lt;/code&gt; to match fails on 4,000 rows the application thought were fine.&lt;/p&gt;
&lt;p&gt;This is the default state of any rule about valid data, eventually. It lives in more than one place. The places drift. The reflex answer, &amp;ldquo;both layers for safety,&amp;rdquo; is what produced the drift in the first place; &amp;ldquo;application-only because we have microservices&amp;rdquo; is the same answer applied to a different fashion cycle. Neither is a decision, both are defaults. The useful question is what each layer can enforce, what it costs, and how often the rule will change. Four axes do the work: scope, cadence, cost, and write-path count.&lt;/p&gt;
&lt;h2 id="the-short-history-of-the-dumb-database-position"&gt;The short history of the &amp;ldquo;dumb database&amp;rdquo; position
&lt;/h2&gt;&lt;p&gt;The microservices canon and the cloud databases built to support it have already answered one half of this question.&lt;/p&gt;
&lt;p&gt;Chris Richardson&amp;rsquo;s &lt;a class="link" href="https://microservices.io/patterns/data/database-per-service.html" target="_blank" rel="noopener"
 &gt;database-per-service pattern&lt;/a&gt; rules out cross-service foreign keys as a design choice: each service owns its schema and no one else touches it. &lt;a class="link" href="https://martinfowler.com/articles/microservices.html" target="_blank" rel="noopener"
 &gt;Fowler and Lewis&amp;rsquo;s &amp;ldquo;Microservices&amp;rdquo;&lt;/a&gt; article coined &amp;ldquo;smart endpoints and dumb pipes&amp;rdquo; and &amp;ldquo;decentralized data management&amp;rdquo;. Neither the middleware nor a shared database holds cross-service logic. Fowler calls the alternative, integration through a shared database, &lt;a class="link" href="https://martinfowler.com/bliki/IntegrationDatabase.html" target="_blank" rel="noopener"
 &gt;the canonical encapsulation breach&lt;/a&gt;. Vaughn Vernon&amp;rsquo;s DDD work puts the consistency boundary at the &lt;a class="link" href="https://www.dddcommunity.org/wp-content/uploads/files/pdf_articles/Vernon_2011_1.pdf" target="_blank" rel="noopener"
 &gt;aggregate&lt;/a&gt;, enforced in process, not in the DBMS.&lt;/p&gt;
&lt;p&gt;The storage layer follows suit. Google Spanner &lt;a class="link" href="https://cloud.google.com/spanner/docs/reference/standard-sql/stored-procedures" target="_blank" rel="noopener"
 &gt;does not support user-defined stored procedures or triggers&lt;/a&gt;; its docs explicitly say that on migration, &amp;ldquo;business logic implemented by database-level stored procedures and triggers must be moved into the application.&amp;rdquo; DynamoDB has no &lt;code&gt;CHECK&lt;/code&gt;, no foreign keys, no triggers; &lt;a class="link" href="https://docs.aws.amazon.com/amazondynamodb/latest/developerguide/Constraints.html" target="_blank" rel="noopener"
 &gt;integrity is a per-item conditional write&lt;/a&gt;. Cassandra, Bigtable, and &lt;a class="link" href="https://www.uber.com/us/en/blog/schemaless-part-one-mysql-datastore/" target="_blank" rel="noopener"
 &gt;Uber&amp;rsquo;s Schemaless&lt;/a&gt; are the same story. Facebook&amp;rsquo;s &lt;a class="link" href="https://engineering.fb.com/2013/06/25/core-infra/tao-the-power-of-the-graph/" target="_blank" rel="noopener"
 &gt;TAO&lt;/a&gt; keeps the social graph&amp;rsquo;s integrity inside TAO itself; the underlying MySQL shards don&amp;rsquo;t enforce it. Shopify, even inside a Rails monolith, &lt;a class="link" href="https://shopify.engineering/shopify-made-patterns-in-our-rails-apps" target="_blank" rel="noopener"
 &gt;doesn&amp;rsquo;t enforce relationships at the database layer&lt;/a&gt;; foreign keys are maintained only in the model code, a choice driven by their sharding and cell architecture.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s the position the last fifteen years of large-scale engineering has converged on, and it&amp;rsquo;s right in the scope it applies to. Across service boundaries, the database physically can&amp;rsquo;t enforce most cross-cutting rules, the dominant cloud storage engines won&amp;rsquo;t host procs or triggers, and the pattern literature has codified the split.&lt;/p&gt;
&lt;p&gt;The mistake is generalizing from this to &amp;ldquo;the database should be dumb, period.&amp;rdquo; That collapses two different debates into one slogan.&lt;/p&gt;
&lt;h2 id="where-the-position-is-strong-and-where-it-isnt"&gt;Where the position is strong and where it isn&amp;rsquo;t
&lt;/h2&gt;&lt;p&gt;The near-unanimous consensus is about cross-service integrity: FK between services, triggers as integration glue, stored procs as the coordination layer. There the answer is genuinely settled. Application-layer, usually in a shared library, sometimes in an orchestration service.&lt;/p&gt;
&lt;p&gt;The within-service question is different. Inside a single service&amp;rsquo;s private schema, with one team owning the reads and writes, the database still sees every write path the service produces: the normal request path, backfill scripts, admin tools, the occasional DBA command at 2am, the new code path the team added last sprint. Richardson, Fowler, and Vernon don&amp;rsquo;t argue against &lt;code&gt;NOT NULL&lt;/code&gt;, &lt;code&gt;CHECK&lt;/code&gt;, or &lt;code&gt;UNIQUE&lt;/code&gt; inside that boundary. Shopify&amp;rsquo;s position is an outlier driven by sharding operations, not ideology. Yugabyte goes further and &lt;a class="link" href="https://www.yugabyte.com/blog/are-stored-procedures-and-triggers-anti-patterns-in-the-cloud-native-world/" target="_blank" rel="noopener"
 &gt;defends stored procedures and triggers inside a service boundary&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;So the real framing: the &amp;ldquo;dumb database&amp;rdquo; position is unanimous across service boundaries and contested within them. The rest of this post is about where the line actually sits within a service. The honest answer is still &amp;ldquo;mostly keep the database lean, but not empty,&amp;rdquo; for reasons that have more to do with deployment cadence and scaling economics than with purity.&lt;/p&gt;
&lt;h2 id="the-four-axes-that-actually-decide-the-split"&gt;The four axes that actually decide the split
&lt;/h2&gt;&lt;p&gt;The rule-by-rule question is a balance across four properties of the system, not a preference between layers.&lt;/p&gt;
&lt;h3 id="1-scope-does-this-rule-cross-service-boundaries"&gt;1. Scope: does this rule cross service boundaries?
&lt;/h3&gt;&lt;p&gt;If the rule spans services, the database can&amp;rsquo;t enforce it. A foreign key into another service&amp;rsquo;s database doesn&amp;rsquo;t exist. A trigger that writes to tables owned by another team isn&amp;rsquo;t compatible with any sane microservices pattern. Cross-service correctness lives in application code, typically in a library that every writing service depends on, or in event-driven compensation (sagas, outbox patterns, eventual-consistency protocols).&lt;/p&gt;
&lt;p&gt;The only databases that let you enforce cross-service rules are ones the pattern literature treats as an anti-pattern on purpose: shared databases with multiple writers.&lt;/p&gt;
&lt;h3 id="2-cadence-how-often-does-this-rule-change"&gt;2. Cadence: how often does this rule change?
&lt;/h3&gt;&lt;p&gt;Application code deploys in minutes. Schema migrations deploy on a migration window, with expand-and-contract dances, &lt;code&gt;NOT VALID&lt;/code&gt; + &lt;code&gt;VALIDATE&lt;/code&gt; phases, and careful ordering across rolling deploys. A rule that lives in the database inherits the database&amp;rsquo;s deployment cadence.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s fine for rules that change annually or never: &amp;ldquo;email column is not null&amp;rdquo;, &amp;ldquo;amount is non-negative&amp;rdquo;, &amp;ldquo;status is one of four values for the life of the product&amp;rdquo;. It&amp;rsquo;s painful for rules that change with product experiments: pricing logic, promotion codes, fraud thresholds, discount stacking rules, feature gates. The friction of modifying a &lt;code&gt;CHECK&lt;/code&gt; constraint or a stored procedure for a rule that&amp;rsquo;s going to change again next quarter adds up to &amp;ldquo;this probably shouldn&amp;rsquo;t have been in the database in the first place.&amp;rdquo;&lt;/p&gt;
&lt;h3 id="3-cost-where-can-this-rule-run-cheapest"&gt;3. Cost: where can this rule run cheapest?
&lt;/h3&gt;&lt;p&gt;The application tier scales horizontally. The primary database, for most OLTP workloads, scales vertically until sharding, and sharding is a project, not a tuning knob. Every CPU cycle spent inside the database is a cycle not spent on I/O, lock management, query planning, or serving other requests. A busy primary at 80% CPU doesn&amp;rsquo;t have slack for an additional stored procedure body to run on every write.&lt;/p&gt;
&lt;p&gt;For a simple &lt;code&gt;CHECK (amount &amp;gt;= 0)&lt;/code&gt;, the cost is measured in nanoseconds per write. Irrelevant. For a trigger that recomputes an aggregate on every insert, the cost is a hot row plus whatever the aggregation costs, charged to the most scarce compute tier in the system. For a stored procedure that loops over rows, the cost is full procedure-body CPU on the primary for every call.&lt;/p&gt;
&lt;p&gt;Application code, by contrast, has near-free horizontal scale. Adding a pod is cheap. Adding database CPU is vertical-scaling dollars until you&amp;rsquo;ve run out of instance sizes, then it&amp;rsquo;s a sharding project.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;The database is a vertical-scaling tier&lt;/strong&gt;
 &lt;div&gt;Moving computation into the database moves it toward the scaling ceiling. Declarative constraints (&lt;code&gt;CHECK&lt;/code&gt;, &lt;code&gt;FK&lt;/code&gt;, &lt;code&gt;UNIQUE&lt;/code&gt;) are cheap enough to be irrelevant. Triggers that do nontrivial work, procedures that run loops, and anything that touches multiple rows per call eat CPU on the one tier that&amp;rsquo;s hardest to scale. The &amp;ldquo;app can do this magnitudes faster&amp;rdquo; intuition is right when &amp;ldquo;faster&amp;rdquo; is measured in throughput under load, not because a single call is faster, but because the application tier absorbs more of them without a scaling event.&lt;/div&gt;
&lt;/div&gt;

&lt;h3 id="4-write-path-count-how-many-things-write-to-this-schema"&gt;4. Write-path count: how many things write to this schema?
&lt;/h3&gt;&lt;p&gt;One service, one codebase, one team, one ORM writing to a schema the team fully owns: application-layer enforcement works. A shared library is the single choke point; every write goes through it.&lt;/p&gt;
&lt;p&gt;More than one writer (multiple services, admin tools in a different language, backfill scripts maintained by a different team, DBA incident-response SQL) and the library has gaps. Every writer that isn&amp;rsquo;t the library bypasses the validation. The database is the only layer that catches them all, and the cost of catching them is a small set of declarative constraints.&lt;/p&gt;
&lt;p&gt;Two writers isn&amp;rsquo;t a lot. Most systems that survive a few years accumulate more: data-migration jobs for a table split, an admin dashboard written in a different stack than the service, a reporting ETL that occasionally writes aggregates back, a partner integration that writes through a shared DB user.&lt;/p&gt;
&lt;h2 id="the-balance-that-holds-in-practice"&gt;The balance that holds in practice
&lt;/h2&gt;&lt;p&gt;The four axes point at a consistent split. Keep the database narrow and declarative. Put everything else in application code, ideally in a library every writer depends on.&lt;/p&gt;
&lt;p&gt;The narrow set the database earns its keep on:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;NOT NULL&lt;/code&gt;, &lt;code&gt;UNIQUE&lt;/code&gt;, &lt;a class="link" href="https://explainanalyze.com/p/foreign-keys-are-not-optional/" &gt;&lt;code&gt;FOREIGN KEY&lt;/code&gt;&lt;/a&gt; within a service&amp;rsquo;s private schema.&lt;/li&gt;
&lt;li&gt;Simple &lt;code&gt;CHECK&lt;/code&gt; constraints for per-row invariants: ranges, regex on identifiers, enum membership.&lt;/li&gt;
&lt;li&gt;Generated columns for derived values that are deterministic, stable, and cheap to compute.&lt;/li&gt;
&lt;li&gt;Indexes the application needs for performance (not business logic, but a reminder they belong in the schema, not in code).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These are declarative, near-zero CPU cost per write, cover every write path, and change rarely enough that the schema&amp;rsquo;s deployment cadence isn&amp;rsquo;t a problem. Foreign keys in particular are the canonical within-service example. &lt;a class="link" href="https://explainanalyze.com/p/foreign-keys-are-not-optional/" &gt;A post on their own&lt;/a&gt; goes deeper on why application-layer referential integrity consistently loses to database-enforced FKs over time, and that argument is this whole post&amp;rsquo;s framework applied to one specific constraint.&lt;/p&gt;
&lt;p&gt;What stays in application code:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Orchestration across multiple statements, services, or external calls.&lt;/li&gt;
&lt;li&gt;Rules that depend on request context, caller identity, time-of-day, or anything outside the row.&lt;/li&gt;
&lt;li&gt;Rules that change with product experiments.&lt;/li&gt;
&lt;li&gt;Rules that span services.&lt;/li&gt;
&lt;li&gt;Computation that would cost measurable database CPU per call.&lt;/li&gt;
&lt;li&gt;Derived values that involve complex business logic or are likely to change.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;If there&amp;rsquo;s one writer, a shared library is the single source of truth. If there are multiple writers (or there will be, which is most systems after a year), the library is still valuable but needs a narrow safety net in the database for the invariants that would corrupt data if they slipped.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;The library as the primary, the schema as the safety net&lt;/strong&gt;
 &lt;div&gt;The pattern that works in practice: a validation library (or a rich domain model) owns the full rule set, including validation messages, business logic, cross-field checks, everything the UI and API need. The schema carries only the declarative subset the database can enforce cheaply: &lt;code&gt;NOT NULL&lt;/code&gt;, &lt;code&gt;CHECK&lt;/code&gt;, &lt;code&gt;UNIQUE&lt;/code&gt;, &lt;code&gt;FK&lt;/code&gt;. When the library&amp;rsquo;s rules diverge from the schema&amp;rsquo;s, the database rejects the write. The schema is the safety net, not the primary enforcement path. Violations surface as 500s that flag drift, not silent corruption.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="check-constraints-the-cheap-defensible-middle-ground"&gt;CHECK constraints, the cheap, defensible middle ground
&lt;/h2&gt;&lt;p&gt;Declarative &lt;code&gt;CHECK&lt;/code&gt; constraints are the strongest example of database-side logic that justifies itself on every axis.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;span class="lnt"&gt;9
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;CHECK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;CHAR&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="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;CHECK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;currency&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;~&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;^[A-Z]{3}$&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;CHECK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;pending&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;paid&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;shipped&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;refunded&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;placed_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;shipped_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;CHECK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;shipped_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;OR&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;shipped_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;placed_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Scope is within the service&amp;rsquo;s schema, applicable. Cadence is annual or never; adding a new status value is a planned migration, not a product-experiment iteration. Cost is near zero, since the planner evaluates the expression once per write and for the operators shown it&amp;rsquo;s nanoseconds. Write-path count covers every path, including the backfill job someone writes next year in a different language.&lt;/p&gt;
&lt;p&gt;The trade-off is real but small. Error messages from a constraint violation are less friendly than a hand-crafted validation message, and adding a &lt;code&gt;CHECK&lt;/code&gt; to a large existing table is a migration project (MySQL rewrites the table; PostgreSQL needs &lt;code&gt;NOT VALID&lt;/code&gt; then &lt;code&gt;VALIDATE CONSTRAINT&lt;/code&gt; to avoid long locks). Both are known problems with known workarounds.&lt;/p&gt;
&lt;p&gt;The common pattern that holds up: application library owns the error message and UX, the database owns the enforcement. The library&amp;rsquo;s check is a fast-path for better errors; the constraint is the gate.&lt;/p&gt;
&lt;h2 id="generated-columns-the-most-underused-declarative-tool"&gt;Generated columns, the most underused declarative tool
&lt;/h2&gt;&lt;p&gt;Generated columns produce a derived value from other columns in the same row. MySQL since 5.7, PostgreSQL since 12. Indexable. Can&amp;rsquo;t be written to. Consistency guaranteed by the engine.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;line_items&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;unit_price_cents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;CHECK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unit_price_cents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;CHECK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total_cents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;GENERATED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ALWAYS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;unit_price_cents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;STORED&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;email_normalized&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;GENERATED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ALWAYS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;LOWER&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;STORED&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email_normalized&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;On the four axes: scope is within-service, cadence is stable (the formula is an identity, not a business rule), cost is negligible (pure arithmetic or string operations), write-path count covers everything because every writer gets the same result automatically. Generated columns are the cleanest way to handle derived values that would otherwise be maintained by discipline.&lt;/p&gt;
&lt;p&gt;The cost: the derivation has to be stable. Changing &lt;code&gt;email_normalized = LOWER(email)&lt;/code&gt; to add Unicode normalization is a migration. If the formula is an active business rule, it&amp;rsquo;s the wrong tool.&lt;/p&gt;
&lt;h2 id="triggers-for-schema-migrations-only"&gt;Triggers, for schema migrations only
&lt;/h2&gt;&lt;p&gt;Triggers run procedural code on insert, update, or delete. That&amp;rsquo;s exactly what makes them wrong for implementation logic. A trigger mutates rows the caller didn&amp;rsquo;t ask to change, fires cascades the caller didn&amp;rsquo;t initiate, and makes &amp;ldquo;this update touches one column&amp;rdquo; a lie. The caller&amp;rsquo;s application logs say one thing; the database does something else. When a bug surfaces, the stack trace goes to application code that never ran the hidden logic.&lt;/p&gt;
&lt;p&gt;The usual defenses (&lt;code&gt;updated_at&lt;/code&gt; maintenance, audit logging, soft-delete cascades, counter caches) are all better handled in application code.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;updated_at&lt;/code&gt; belongs in the ORM&amp;rsquo;s model callback, the shared write library, or a middleware that sets it on every persist. Every writer already goes through that path, and adding a timestamp is one line. If backfill scripts or admin tools bypass the library, the fix is to make them use the library, not to paper over the gap with a trigger.&lt;/p&gt;
&lt;p&gt;Audit logs need application context: the user ID, the request ID, the reason, the session, the tenant. A trigger can&amp;rsquo;t see any of that without awkward session-variable tricks that break across connection pools. Write the audit row in application code, next to the logic that knows why the change is happening.&lt;/p&gt;
&lt;p&gt;Soft-delete cascades are business rules. Which child rows get deleted when a parent is soft-deleted, in what order, with what side effects, is a product decision, not a storage concern. Orchestrate it in the application.&lt;/p&gt;
&lt;p&gt;Counter caches via trigger create a hot row where every concurrent write serializes on the same parent lock. Application-side counters, background rollups, or a separate events-with-aggregation pipeline all scale better and leave the hot path free.&lt;/p&gt;
&lt;p&gt;The general principle: application logic should be visible in application code. A trigger that modifies data the application wrote is a hidden side effect, and hidden side effects are an anti-pattern for the same reason global variables are. They make the reachable state of the system larger than the code the reader is looking at.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;The debugging cost is the real cost&lt;/strong&gt;
 &lt;div&gt;When an on-call engineer is looking at a production incident, they read the application code that ran. A trigger that fired three levels down, in a language they may not read fluently, mutating rows nobody expected, is the single biggest source of &amp;ldquo;the code says X, the database did Y&amp;rdquo; incidents. That&amp;rsquo;s not a tooling problem. It&amp;rsquo;s a design choice that can be avoided by not writing triggers as implementation.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;This gap widens when an ORM sits between the application and the database. ORMs model what they created (columns and relations) and don&amp;rsquo;t reflect triggers, &lt;code&gt;CHECK&lt;/code&gt; constraints, or generated columns in the model class. A trigger that mutates a row after insert produces data the ORM didn&amp;rsquo;t know would be there, and the in-memory object diverges from the persisted row until someone thinks to reload. &lt;a class="link" href="https://explainanalyze.com/p/orms-are-a-coupling-not-an-abstraction/#database-side-logic-doesnt-round-trip" &gt;The ORM coupling post&lt;/a&gt; covers this failure mode in more depth; triggers are one of the specific shortfalls that show up as &amp;ldquo;the model says one thing, the database did another.&amp;rdquo;&lt;/p&gt;
&lt;h3 id="the-legitimate-case-schema-migrations"&gt;The legitimate case: schema migrations
&lt;/h3&gt;&lt;p&gt;The one place triggers earn their keep is time-bounded, explicit migration work. During an expand-and-contract schema change (renaming a column, splitting a table, changing a type), a trigger can dual-write between the old and new shape so that mixed old-application and new-application traffic both see consistent data. The trigger exists for the duration of the migration window and is dropped once the backfill is complete and all writers are on the new shape.&lt;/p&gt;
&lt;p&gt;This is trigger-as-scaffolding. A temporary mechanism that bridges a specific transition, with a clear removal criterion. It doesn&amp;rsquo;t hide business logic; it handles transitional compatibility between two versions of a schema while the application rolls forward.&lt;/p&gt;
&lt;p&gt;The most common real-world instance of this pattern in MySQL is &lt;a class="link" href="https://docs.percona.com/percona-toolkit/pt-online-schema-change.html" target="_blank" rel="noopener"
 &gt;Percona&amp;rsquo;s &lt;code&gt;pt-online-schema-change&lt;/code&gt;&lt;/a&gt;: it creates a shadow table with the target schema, installs &lt;code&gt;INSERT&lt;/code&gt;/&lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;DELETE&lt;/code&gt; triggers on the original to replicate writes into the shadow while data is copied in chunks, then atomically renames and drops the triggers. The triggers exist for the migration&amp;rsquo;s duration and nothing longer. In PostgreSQL, &lt;a class="link" href="https://github.com/xataio/pgroll" target="_blank" rel="noopener"
 &gt;&lt;code&gt;pgroll&lt;/code&gt;&lt;/a&gt; does the same kind of dual-write-via-trigger for zero-downtime schema changes. Both treat triggers exactly as this section argues they should be treated: time-bounded scaffolding with an explicit tear-down step.&lt;/p&gt;
&lt;p&gt;Worth noting the counter-example. &lt;a class="link" href="https://github.com/github/gh-ost" target="_blank" rel="noopener"
 &gt;GitHub&amp;rsquo;s &lt;code&gt;gh-ost&lt;/code&gt;&lt;/a&gt; performs the same migrations without triggers, reading the binlog instead. Their stated reason is that triggers add synchronous load to the primary during the migration and share its locking fate. That argument is about migration tooling trade-offs, not a defense of triggers in application logic. The conclusion in both camps is the same: triggers outside of migration scaffolding don&amp;rsquo;t earn their keep.&lt;/p&gt;
&lt;p&gt;Everything outside that narrow case (cross-cutting concerns, derived values, audit logs, product rules) belongs in application code where it&amp;rsquo;s visible, testable, and traceable from the same stack trace as the logic that caused the write.&lt;/p&gt;
&lt;h3 id="how-companies-end-up-with-triggers-anyway"&gt;How companies end up with triggers anyway
&lt;/h3&gt;&lt;p&gt;A large share of production databases carrying heavy trigger logic didn&amp;rsquo;t get there by choice. They got there by losing track of the write boundary. The pattern is predictable. A database starts as one service&amp;rsquo;s store. A second team needs the same data and connects directly because it&amp;rsquo;s easier than building an API. A data-warehouse ETL starts writing back aggregates. An analytics job needs a &amp;ldquo;last seen&amp;rdquo; column updated. A partner integration gets a read-write user &amp;ldquo;just for this quarter.&amp;rdquo; Five years later the database has a dozen clients, some inside the company, some not, some on systems nobody actively maintains, and nobody has a full list.&lt;/p&gt;
&lt;p&gt;At that point, asking every writer to go through a shared library stops being possible. The library is only the single source of truth if every writer imports it, and &amp;ldquo;every writer&amp;rdquo; now includes a Java batch job, a Go analytics worker, a legacy PHP admin tool, a vendor ETL, and a spreadsheet someone&amp;rsquo;s been running for years. The company doesn&amp;rsquo;t know where all the calls are coming from, so moving rules into an API layer isn&amp;rsquo;t an option. There&amp;rsquo;s no API layer every caller can be forced through.&lt;/p&gt;
&lt;p&gt;The database, meanwhile, sees every writer. That&amp;rsquo;s how a team ends up with a trigger enforcing a rule that should have been in application code. The trigger is the only remaining place. It&amp;rsquo;s a symptom of losing the boundary, not a design choice made on its merits.&lt;/p&gt;
&lt;p&gt;The real lesson is that the boundary is the thing worth defending. Once multiple unknown clients are writing to a schema, every future rule either becomes a trigger by necessity or goes un-enforced. Greenfield systems should treat &amp;ldquo;who is allowed to write to this schema&amp;rdquo; as a first-class architectural decision, with one service in front of it and everyone else going through that service. Migrations out of the trap exist (service extraction, proxying direct-DB clients through a write API, introducing a write-time event bus) but they&amp;rsquo;re multi-quarter projects, and the trigger layer usually stays in place throughout because it&amp;rsquo;s doing the job nothing else is available to do.&lt;/p&gt;
&lt;h2 id="stored-procedures-the-vertical-scaling-trap"&gt;Stored procedures, the vertical-scaling trap
&lt;/h2&gt;&lt;p&gt;Stored procedures move application logic into the database process. They&amp;rsquo;re the tool most directly opposed to the &amp;ldquo;database as storage&amp;rdquo; position, and the one with the clearest scaling argument against them. On the four axes, stored procedures fail most of them for general business logic.&lt;/p&gt;
&lt;p&gt;Scope is within one database. Across services, impossible (which is part of why Spanner and DynamoDB don&amp;rsquo;t support them). Cadence is schema-migration speed; a product rule that needs a hotfix takes a migration. Cost is the procedure body running on the primary&amp;rsquo;s CPU, competing with every query for the same scarce resource, when the application tier could run the same logic on a pod that scales horizontally. Write-path count is the one axis where procedures are strongest: if the procedure is the only way to perform the operation, every write path is covered.&lt;/p&gt;
&lt;p&gt;The narrow case for stored procedures is the intersection of those trade-offs. Operations that must be atomic, must cover every write path, and would be prohibitively expensive to run row-by-row over the network. Bulk data operations that are genuinely row-by-row expensive. Security boundaries where the application is explicitly not trusted with direct table access. Legacy systems where procedures are the system of record.&lt;/p&gt;
&lt;p&gt;Outside those cases, stored procedures trade a scaling-ceiling problem and a deployment-cadence problem for centralization that a shared application library provides at lower cost. The argument that &amp;ldquo;a stored procedure prevents the application from drifting&amp;rdquo; is real, and the same argument applies to a validation library without the scaling or deployment penalty.&lt;/p&gt;
&lt;h2 id="views-the-quietly-useful-option"&gt;Views, the quietly useful option
&lt;/h2&gt;&lt;p&gt;Views don&amp;rsquo;t enforce writes but they do shape reads, and shaping reads affects correctness in practice. A view that filters soft-deleted rows means every consumer sees the same definition of &amp;ldquo;active&amp;rdquo;. Updatable views can also be a migration-compatibility tool.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VIEW&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;active_orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;deleted_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Scope is within-service. Cadence is fine either way; view bodies change as often as the underlying queries. Cost is the planner expanding views at query time, and complex views can hide expensive plans from the caller. Write-path count is read-time only, so views don&amp;rsquo;t help with integrity.&lt;/p&gt;
&lt;p&gt;Views are underused for their cheap benefits (canonical join shapes, soft-delete filtering, migration shims) and overused when they become a layer of logic the calling code can&amp;rsquo;t see. Materialized views are a separate topic; they add refresh-cadence questions the live-query tools don&amp;rsquo;t.&lt;/p&gt;
&lt;h2 id="derived-columns-and-counter-caches-implicit-logic"&gt;Derived columns and counter caches, implicit logic
&lt;/h2&gt;&lt;p&gt;Comment counts, follower counts, status summaries, running totals. Every one of these encodes business logic; the question is which mechanism maintains it.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;posts&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;author_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;comment_count&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DEFAULT&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;last_comment_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Through the four-axis lens, four mechanisms:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Application code maintains it.&lt;/strong&gt; Cadence is fast. Cost is zero on the DB, per-write work on the app tier. Write-path count fails if any writer skips the library. Scope is fine within the service.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Materialized view or batch job.&lt;/strong&gt; Cadence is decoupled from the write. Cost is the refresh window. Write-path count covers everything, but the value is stale between refreshes. Scope is within-service.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Read-time aggregation.&lt;/strong&gt; Cadence is irrelevant. Cost is per-read and can be expensive on feed-style queries. Write-path count is always correct. Scope is within-service.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Separate counter service with async events.&lt;/strong&gt; Cadence is fast. Cost is extra infrastructure and delivery semantics to reason about. Write-path count covers everything if every writer publishes the event. Scope is any.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A trigger is conspicuously absent from that list on purpose. Counter-cache triggers are the canonical example of hidden logic causing a contention problem the application team can&amp;rsquo;t see: every concurrent comment insert serializes on the parent post&amp;rsquo;s row lock, and the debugging path goes straight through PL/pgSQL the service engineers didn&amp;rsquo;t write. The four-axis analysis points instead at the library-maintained counter when there&amp;rsquo;s one writer, the background rollup when reads are hot, and a separate counter service at scale or across boundaries.&lt;/p&gt;
&lt;h2 id="the-library-pattern-done-seriously"&gt;The library pattern, done seriously
&lt;/h2&gt;&lt;p&gt;The natural consequence of &amp;ldquo;narrow database, logic in application&amp;rdquo; is that the application layer&amp;rsquo;s logic has to be reusable. A validation that only lives in one service&amp;rsquo;s Rails app isn&amp;rsquo;t a library, it&amp;rsquo;s service code. A library every writer imports is the actual mechanism.&lt;/p&gt;
&lt;p&gt;Four shapes show up in practice:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Monolith, one language.&lt;/strong&gt; A package inside the codebase, imported by every write path. Works well. Admin tools and background jobs depend on the same package as the web request path. Backfill scripts should depend on it too; in practice this is where discipline breaks down.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Microservices, one language.&lt;/strong&gt; A shared library published as a package. Every service depends on the same version, or accepts that a rollout takes a deploy cycle across services. Version skew is the operational tax.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Polyglot services.&lt;/strong&gt; A shared library doesn&amp;rsquo;t exist. Validation gets reimplemented per service, or pushed into a validation service that every caller hits over RPC. The RPC option is real and works; it turns &amp;ldquo;shared library&amp;rdquo; into &amp;ldquo;shared service&amp;rdquo; with the same logical role.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Schema-first code generation.&lt;/strong&gt; Tools like &lt;a class="link" href="https://sqlc.dev/" target="_blank" rel="noopener"
 &gt;sqlc&lt;/a&gt; and &lt;a class="link" href="https://www.jooq.org/" target="_blank" rel="noopener"
 &gt;jOOQ&lt;/a&gt; generate typed client code from the schema, which gives a narrow kind of library reuse (type safety and query shapes) without attempting to encode business logic. For logic itself, schemas aren&amp;rsquo;t enough; the library is separate.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The discipline that makes this work: the library is the only write path, and if it isn&amp;rsquo;t, the database&amp;rsquo;s declarative constraints are the backup. The two pieces reinforce each other. The library holds the full rule set, fast and rich and horizontal-scale. The schema holds the small subset the database can enforce cheaply and that every writer, library or not, has to pass through.&lt;/p&gt;
&lt;h2 id="the-duplication-trap"&gt;The duplication trap
&lt;/h2&gt;&lt;p&gt;The most common failure mode isn&amp;rsquo;t picking the wrong layer. It&amp;rsquo;s picking both without deciding which is authoritative.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Application validator: &lt;code&gt;email&lt;/code&gt; must match regex A.&lt;/li&gt;
&lt;li&gt;Database &lt;code&gt;CHECK&lt;/code&gt;: &lt;code&gt;email&lt;/code&gt; must match regex B.&lt;/li&gt;
&lt;li&gt;Over the years, one gets updated (for GDPR, for internationalization); the other doesn&amp;rsquo;t.&lt;/li&gt;
&lt;li&gt;Legacy rows exist that pass the old version but not the new one.&lt;/li&gt;
&lt;li&gt;A migration that tries to tighten the &lt;code&gt;CHECK&lt;/code&gt; fails on legacy rows the application thought were fine.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The pattern repeats with status enums, numeric ranges, referential rules, and soft-delete semantics. Two versions of the truth stay in sync as long as someone is actively keeping them in sync, and then they don&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;The useful framing: pick one layer as authoritative and name the other as a UX mirror or a safety net. The authoritative layer is the one that runs when the other doesn&amp;rsquo;t, which, for correctness invariants where write paths multiply, still points at the database for the narrow declarative subset.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- authoritative: the declarative CHECK
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CHECK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;pending&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;active&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;closed&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# mirror in the library: better errors, fast-fail before the round trip&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;validate_status&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;value&lt;/span&gt; &lt;span class="ow"&gt;not&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;pending&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;active&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;closed&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;ValidationError&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;Status must be pending, active, or closed.&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;If the library and the schema disagree, the schema wins and the write fails. The failure is loud, traceable, and tells you the drift exists, instead of the silent corruption you get when neither layer enforces a rule.&lt;/p&gt;
&lt;h2 id="rules-the-assistant-can-see"&gt;Rules the assistant can see
&lt;/h2&gt;&lt;p&gt;The choice of where to put a rule is, among other things, a choice about which readers can see it. An AI assistant writing SQL or application code against the schema reads the catalog (column types, constraints, FKs, CHECK definitions) and whatever source files the prompt happens to include. Declarative rules show up in &lt;code&gt;information_schema&lt;/code&gt; and &lt;code&gt;pg_constraint&lt;/code&gt;. The assistant can reason about them without being pointed at additional files. A &lt;code&gt;CHECK (status IN ('pending', 'active', 'closed'))&lt;/code&gt; is visible to any schema-reading tool on day one.&lt;/p&gt;
&lt;p&gt;Rules living in triggers, stored procedures, ORM callbacks, or a shared Python validation library don&amp;rsquo;t surface when the same tool reads the catalog. The write path enforces them at runtime; the schema doesn&amp;rsquo;t describe them. A model generating an INSERT statement against a table whose uniqueness is enforced only by a before-insert trigger will produce a query that looks correct and violates an invariant the catalog never mentioned. This doesn&amp;rsquo;t change the conclusion that most logic belongs in the application, but it does tip the math, at the margin, toward the narrow set of correctness invariants where declarative constraints pay double: they enforce on every write path, and they&amp;rsquo;re the only form of the rule a schema-reading assistant sees for free.&lt;/p&gt;
&lt;h2 id="trade-offs"&gt;Trade-offs
&lt;/h2&gt;&lt;p&gt;Every position in this post has counter-arguments, and they&amp;rsquo;re real.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Declarative database constraints lock you into SQL semantics.&lt;/strong&gt; A &lt;code&gt;CHECK&lt;/code&gt; constraint doesn&amp;rsquo;t survive a migration to DynamoDB or Spanner without rework. Teams building for a future migration accept less database-side logic in exchange for portability. The trade is real; the frequency of actual cross-engine migrations is lower than the frequency of discussions about them.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Schema changes are slow enough that even &amp;ldquo;simple&amp;rdquo; constraints are friction.&lt;/strong&gt; Adding a &lt;code&gt;CHECK&lt;/code&gt; to a 500M-row table is a migration project. For teams shipping schema changes weekly, every constraint is a cost, and sometimes the cheaper answer is to accept looser database-side invariants and stricter application-side ones.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Application-side validation is easier to test, version, and roll back.&lt;/strong&gt; A library&amp;rsquo;s tests run in milliseconds; a constraint&amp;rsquo;s tests need a real database. Teams with weak integration-testing infrastructure end up under-testing database-side rules.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Horizontal-scaling arithmetic isn&amp;rsquo;t universal.&lt;/strong&gt; For services running on a single database at moderate load, the &amp;ldquo;vertical scaling ceiling&amp;rdquo; argument is an abstraction. The primary has plenty of headroom and the scaling argument is theoretical. The argument matters more as traffic grows.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Shopify&amp;rsquo;s position is internally consistent.&lt;/strong&gt; No database-level foreign keys, all integrity in models, sharded storage. It works because every write path goes through Rails and because the operational investment in model-layer integrity is serious. A smaller team without that investment can&amp;rsquo;t safely adopt the same pattern; the constraints in the database are what a smaller team can afford.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Stored procedures aren&amp;rsquo;t universally bad.&lt;/strong&gt; The Yugabyte post is right that in a single-service OLTP context, procedures can centralize logic effectively. The scaling argument is real but not always the binding constraint. Teams with deep SQL skills and disciplined version-control-for-procedures can extract more value than the &amp;ldquo;avoid them&amp;rdquo; position suggests.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The balance described above is what holds across the most common cases. Specific cases have specific answers. The failure mode is rarely picking the wrong point on the axis. It&amp;rsquo;s not picking at all.&lt;/p&gt;
&lt;h2 id="a-rule-by-rule-framework"&gt;A rule-by-rule framework
&lt;/h2&gt;&lt;p&gt;Instead of a blanket policy, a set of questions that point at the right layer per rule.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Does the rule cross service boundaries?&lt;/strong&gt; If yes, application library or orchestration service. The database can&amp;rsquo;t help.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Would violation corrupt data?&lt;/strong&gt; If yes, the database should enforce it as a declarative constraint, because every write path has to be covered.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Is the rule a derived value with a stable formula?&lt;/strong&gt; Generated column. Cheap, covers every writer, zero sync code.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Is the rule a derived value with a changing formula or external inputs?&lt;/strong&gt; Application library.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Does the rule depend on anything outside the row (request context, external services, feature flags)?&lt;/strong&gt; Application library.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Does the rule change more often than quarterly?&lt;/strong&gt; Application library.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Is the rule a cross-cutting concern every write path needs (timestamps, audit logs)?&lt;/strong&gt; Application library that every writer imports, not a trigger. The trigger hides the logic; the library makes it visible to the reader of the code that caused the write.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Does the rule involve non-trivial computation or touch multiple rows per call?&lt;/strong&gt; Application library. Database CPU is the scarce tier.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Is there more than one write path?&lt;/strong&gt; The library alone isn&amp;rsquo;t enough; declarative constraints in the schema are the backup.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The questions don&amp;rsquo;t eliminate judgment (several rules will land on edges) but they make the trade-offs visible and keep decisions from being driven by which layer the author was working in when the rule came up.&lt;/p&gt;
&lt;h2 id="the-bigger-picture"&gt;The bigger picture
&lt;/h2&gt;&lt;p&gt;Across services, the database is storage and logic lives in services and shared libraries. That&amp;rsquo;s the direction Spanner, DynamoDB, Cassandra, and the pattern literature all point, and the cross-service question is genuinely settled. Within a service it&amp;rsquo;s softer. The database can enforce things the application can&amp;rsquo;t, a narrow set of declarative constraints costs almost nothing, and the schema is the only layer that sees every writer the library&amp;rsquo;s author didn&amp;rsquo;t plan for. Keep the database lean. Put the full rule set in a library the application owns. Let the schema carry the small subset that catches the writes the library missed (which is more writes than anyone planning the system thought there would be).&lt;/p&gt;</description></item><item><title>Database Deadlocks, Part 2: Diagnosis, Retries, and Prevention</title><link>https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/</link><pubDate>Sun, 02 Mar 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Database Deadlocks, Part 2: Diagnosis, Retries, and Prevention" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;&lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-1-the-patterns/" target="_blank" rel="noopener"
 &gt;Part 1&lt;/a&gt; covered the patterns. This post is the operational half: reading the deadlock log to identify which pattern fired, designing retries that fail loudly instead of hiding the real bug, isolating hot rows before they become incidents, and the prevention primitives (&lt;code&gt;NOWAIT&lt;/code&gt;, &lt;code&gt;SKIP LOCKED&lt;/code&gt;, isolation-level changes, &lt;code&gt;lock_timeout&lt;/code&gt;) that remove entire categories from the workload.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;A nightly inventory sync deadlocks every Tuesday at 02:14. The job runs &lt;code&gt;INSERT INTO inventory ... ON CONFLICT (sku) DO UPDATE&lt;/code&gt; across eighteen partner warehouses in parallel, and the deadlock counter spikes from 3 per hour to 400 per hour for the duration of the run. The on-call response over the last three incidents has been the same: bump the retry limit from 5 to 10. The deadlocks still happen, they just take longer to surface as user-facing errors. After the third week, someone reads the &lt;code&gt;LATEST DETECTED DEADLOCK&lt;/code&gt; output and finds &lt;code&gt;lock_mode X locks rec but not gap&lt;/code&gt; on the primary key. The workers aren&amp;rsquo;t sorting the upsert batches. One worker processes &lt;code&gt;(1, 2, 3)&lt;/code&gt; while another processes &lt;code&gt;(3, 2, 1)&lt;/code&gt;, and they deadlock on the middle row.&lt;/p&gt;
&lt;p&gt;The fix is one line in application code: &lt;code&gt;rows.sort(key=lambda r: r.sku)&lt;/code&gt; before each batch. The deadlock counter drops back to baseline immediately. The retry limit goes back to 5. The three weeks of bumping retry settings were chasing the symptom; the actual fix lived in the access path, not the retry layer. The log told the story the first time it was generated.&lt;/p&gt;
&lt;p&gt;This post is the operational half of the deadlocks series. &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-1-the-patterns/" &gt;Part 1&lt;/a&gt; covered the patterns. What follows is the sequence: read the log to identify which pattern fired, design retries that don&amp;rsquo;t hide the bug, then reach for the prevention primitives that remove categories from the workload entirely.&lt;/p&gt;
&lt;h2 id="read-the-mysql-deadlock-log"&gt;Read the MySQL deadlock log
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;SHOW ENGINE INNODB STATUS&lt;/code&gt; dumps the most recent deadlock in the &lt;code&gt;LATEST DETECTED DEADLOCK&lt;/code&gt; section. The catch: only the most recent. On a busy system, deadlocks overwrite each other faster than someone can log in and copy the output. Before anything else, turn on &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/innodb-parameters.html#sysvar_innodb_print_all_deadlocks" target="_blank" rel="noopener"
 &gt;&lt;code&gt;innodb_print_all_deadlocks&lt;/code&gt;&lt;/a&gt;&lt;code&gt; = ON&lt;/code&gt; in every production deployment. It writes every deadlock to the error log instead of a single overwriting slot. The volume is negligible, the diagnostic value is high, and there is no downside.&lt;/p&gt;
&lt;p&gt;A representative entry looks like this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;span class="lnt"&gt;19
&lt;/span&gt;&lt;span class="lnt"&gt;20
&lt;/span&gt;&lt;span class="lnt"&gt;21
&lt;/span&gt;&lt;span class="lnt"&gt;22
&lt;/span&gt;&lt;span class="lnt"&gt;23
&lt;/span&gt;&lt;span class="lnt"&gt;24
&lt;/span&gt;&lt;span class="lnt"&gt;25
&lt;/span&gt;&lt;span class="lnt"&gt;26
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-mysql" data-lang="mysql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;***&lt;/span&gt;&lt;span class="w"&gt; &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="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TRANSACTION&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;TRANSACTION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4823941&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ACTIVE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;sec&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;starting&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;index&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;read&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;mysql&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kp"&gt;tables&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;use&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;locked&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LOCK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;WAIT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;lock&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;struct&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="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;heap&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;size&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1136&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="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;lock&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="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;MySQL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;thread&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;892&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;OS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;thread&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;handle&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="n"&gt;x7f&lt;/span&gt;&lt;span class="p"&gt;...,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;18293&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;shipped&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1001&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;***&lt;/span&gt;&lt;span class="w"&gt; &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="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;WAITING&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FOR&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;THIS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LOCK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;GRANTED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;RECORD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;LOCKS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;space&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;no&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;112&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bits&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;144&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;index&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;`&lt;/span&gt;&lt;span class="n"&gt;shop&lt;/span&gt;&lt;span class="o"&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;orders&lt;/span&gt;&lt;span class="o"&gt;`&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;trx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4823941&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;lock_mode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;locks&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;but&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;gap&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;waiting&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;***&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TRANSACTION&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;TRANSACTION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4823942&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ACTIVE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;sec&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;starting&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;index&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;read&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kt"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;paid&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1002&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;***&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;HOLDS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;THE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LOCK&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="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;RECORD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;LOCKS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;space&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;no&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;112&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bits&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;144&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;index&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;`&lt;/span&gt;&lt;span class="n"&gt;shop&lt;/span&gt;&lt;span class="o"&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;orders&lt;/span&gt;&lt;span class="o"&gt;`&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;trx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4823942&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;lock_mode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;locks&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;but&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;gap&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;***&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;WAITING&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FOR&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;THIS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LOCK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;GRANTED&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;RECORD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;LOCKS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;space&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;48&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;page&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;no&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;112&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;n&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bits&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;144&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;index&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;of&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;table&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;`&lt;/span&gt;&lt;span class="n"&gt;shop&lt;/span&gt;&lt;span class="o"&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;orders&lt;/span&gt;&lt;span class="o"&gt;`&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;trx&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4823942&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;lock_mode&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;X&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;locks&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rec&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;but&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;not&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;gap&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;waiting&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="o"&gt;***&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;WE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ROLL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;BACK&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nf"&gt;TRANSACTION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The parts that matter:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;lock_mode&lt;/code&gt; vs. &lt;code&gt;lock_type&lt;/code&gt;.&lt;/strong&gt; &lt;code&gt;X&lt;/code&gt; is exclusive, &lt;code&gt;S&lt;/code&gt; is shared. &lt;code&gt;locks rec but not gap&lt;/code&gt; is a pure record lock; &lt;code&gt;locks gap before rec&lt;/code&gt; is a gap lock; the unadorned &lt;code&gt;X&lt;/code&gt; under &lt;code&gt;REPEATABLE READ&lt;/code&gt; is usually next-key (record + gap). Matching &lt;code&gt;lock_mode S locks rec but not gap&lt;/code&gt; against &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-1-the-patterns/#unique-index-deadlocks-are-a-category-of-their-own" &gt;Part 1&amp;rsquo;s unique-index section&lt;/a&gt; tells you immediately that this is a duplicate-key-on-insert deadlock.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;index&lt;/code&gt; name.&lt;/strong&gt; &lt;code&gt;index PRIMARY&lt;/code&gt; vs. &lt;code&gt;index idx_customer&lt;/code&gt; reveals whether the cycle formed on the clustered index or a secondary one. Two transactions approaching the same rows from different indexes is the &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-1-the-patterns/#index-scans-lock-more-rows-than-queries-return" &gt;&amp;ldquo;secondary index locks on InnoDB&amp;rdquo; pattern from Part 1&lt;/a&gt;; the fix is usually consolidating access paths.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;The query text.&lt;/strong&gt; This is the last statement the transaction executed before the deadlock, not necessarily the one that caused it. A transaction holding locks from three earlier statements can deadlock on the fourth, and the log only shows the fourth. Cross-reference with the application&amp;rsquo;s structured logs to reconstruct the full transaction.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;trx id&lt;/code&gt;&lt;/strong&gt; is monotonically increasing and stable for the life of the transaction. Searching the general log or slow-query log for that &lt;code&gt;trx id&lt;/code&gt; reconstructs the full statement sequence, but only if general-query logging is on for the window in question, which it usually isn&amp;rsquo;t.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;&lt;code&gt;performance_schema.data_locks&lt;/code&gt; and &lt;code&gt;data_lock_waits&lt;/code&gt; give a real-time view of current locks and waits. Useful for catching a deadlock-adjacent pathology (long wait chains, hot rows) before the cycle forms:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lock_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;lock_mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;object_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;index_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&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;REQUESTING_ENGINE_TRANSACTION_ID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;waiting_trx&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&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;BLOCKING_ENGINE_TRANSACTION_ID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;blocking_trx&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;performance_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data_lock_waits&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;w&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;performance_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;data_locks&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bl&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&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;BLOCKING_ENGINE_LOCK_ID&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;ENGINE_LOCK_ID&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;bl&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;OBJECT_SCHEMA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;shop&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;h2 id="read-the-postgresql-deadlock-log"&gt;Read the PostgreSQL deadlock log
&lt;/h2&gt;&lt;p&gt;PostgreSQL&amp;rsquo;s diagnostic story is narrower by design. Deadlocks are logged automatically when the cycle is detected. &lt;code&gt;log_lock_waits = on&lt;/code&gt; logs any wait exceeding &lt;code&gt;deadlock_timeout&lt;/code&gt; (default 1s), which catches the wait-chain escalation before the detector fires. There&amp;rsquo;s no equivalent to &lt;code&gt;SHOW ENGINE INNODB STATUS&lt;/code&gt;; everything lives in &lt;code&gt;postgresql.log&lt;/code&gt; or the extensions you&amp;rsquo;ve installed.&lt;/p&gt;
&lt;p&gt;A representative deadlock entry:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;span class="lnt"&gt;9
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-zed" data-lang="zed"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;ERROR&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;deadlock&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;detected&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;DETAIL&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Process&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;14234&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;waits&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ShareLock&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;89234&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;blocked&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;14235&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Process&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;14235&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;waits&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ShareLock&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;on&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;transaction&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;89233&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;blocked&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;by&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;process&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;14234&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Process&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;14234&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;100&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;2&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;Process&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;14235&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;50&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;1&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;HINT&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;See&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;server&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;log&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;for&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;details&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;CONTEXT&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;while&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;updating&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tuple&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="err"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="err"&gt;18&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;in&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="kn"&gt;relation&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="err"&gt;&amp;#34;&lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="err"&gt;&amp;#34;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The &lt;code&gt;ShareLock on transaction X&lt;/code&gt; wording is PostgreSQL-specific. One transaction is waiting to see the commit status of another (a row-lock wait manifests as waiting on the holder&amp;rsquo;s transaction ID). The tuple identifier &lt;code&gt;(0,18)&lt;/code&gt; points to the exact physical row (page 0, tuple 18 in the heap), which is useful for reproducing the scenario but changes as rows are updated (MVCC creates new versions at new &lt;code&gt;(page, tuple)&lt;/code&gt; locations).&lt;/p&gt;
&lt;p&gt;For real-time inspection, &lt;code&gt;pg_locks&lt;/code&gt; joined against &lt;code&gt;pg_stat_activity&lt;/code&gt; shows live lock state:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;usename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait_event_type&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;wait_event&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;mode&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;locktype&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;relation&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;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pg_blocking_pids&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;blocked_by&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &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;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;80&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LEFT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pg_locks&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;l&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;granted&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;idle&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;xact_start&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;pg_blocking_pids(pid)&lt;/code&gt; returns the array of PIDs blocking a given transaction. Walking it recursively reconstructs the live wait-for graph: the same data the deadlock detector uses, just before a cycle forms. For hot systems, &lt;code&gt;pg_stat_statements&lt;/code&gt; combined with &lt;code&gt;pg_stat_activity&lt;/code&gt; snapshots at regular intervals builds a picture of which statements accumulate the most wait time, which is almost always the right first place to look.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;Row locks are invisible in pg_locks&lt;/strong&gt;
 &lt;div&gt;&lt;p&gt;PostgreSQL&amp;rsquo;s row-level locks (the result of &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt;, FK checks, and plain &lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;DELETE&lt;/code&gt;) are stored on the tuple itself, in the &lt;code&gt;xmax&lt;/code&gt; system column, not in the shared lock table. They don&amp;rsquo;t show up in &lt;code&gt;pg_locks&lt;/code&gt;. The only way to see them is through the &lt;code&gt;pgrowlocks&lt;/code&gt; extension, which scans the heap directly:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;EXTENSION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pgrowlocks&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pgrowlocks&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;accounts&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;This is the single biggest difference between PG and InnoDB lock introspection, and the reason PG operators often feel blind to row-level contention until a cycle actually forms.&lt;/p&gt;
&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="handle-serializable-serialization-failures-separately"&gt;Handle SERIALIZABLE serialization failures separately
&lt;/h2&gt;&lt;p&gt;Under &lt;code&gt;SERIALIZABLE&lt;/code&gt; isolation, PostgreSQL uses Serializable Snapshot Isolation (SSI), an optimistic mechanism based on SIREAD predicate locks that track read-write dependencies between transactions. SSI cannot deadlock by design; it never blocks on lock acquisition. What it does is abort one transaction with a serialization failure when it detects a dangerous read-write dependency cycle that would violate serializability.&lt;/p&gt;
&lt;p&gt;The two failure codes look similar but have fundamentally different semantics:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;40001 serialization_failure&lt;/code&gt;.&lt;/strong&gt; SSI detected a dependency cycle and aborted the transaction before it could commit a non-serializable result. The transaction did nothing wrong; the combination of its operations with a concurrent transaction would have produced an inconsistency. Retrying is always safe and usually succeeds (the concurrent transaction will have committed or aborted, so the second attempt doesn&amp;rsquo;t see the same conflict).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;40P01 deadlock_detected&lt;/code&gt;.&lt;/strong&gt; A cycle in the wait-for graph was broken by killing a victim. Retrying may or may not succeed depending on what caused the cycle. If the cycle was deterministic (two code paths with inconsistent ordering), it will keep recurring.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The practical consequence for retry architecture: an application running under &lt;code&gt;SERIALIZABLE&lt;/code&gt; must handle 40001. It&amp;rsquo;s not a deadlock, it&amp;rsquo;s the normal failure mode of SSI, and retries are the only recovery path. An application running under &lt;code&gt;READ COMMITTED&lt;/code&gt; never sees 40001. An application that handles 40001 identically to 40P01 is correct but coarse. The right granularity is: always retry 40001 (the workload&amp;rsquo;s own correctness guarantee assumes this); retry 40P01 with caution and escalate on repeat.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;def&lt;/span&gt; &lt;span class="nf"&gt;retry_on_conflict&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;max_attempts&lt;/span&gt;&lt;span class="p"&gt;):&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt; &lt;span class="n"&gt;fn&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;SerializationFailure&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;# 40001: always retry. SSI guarantees make this safe.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="n"&gt;psycopg&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;errors&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;DeadlockDetected&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="c1"&gt;# 40P01: retry with caution; log for root-cause analysis.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;log_deadlock_for_analysis&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;backoff&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;TransactionRetryExhausted&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;h2 id="architect-retries-that-surface-bugs-not-bury-them"&gt;Architect retries that surface bugs, not bury them
&lt;/h2&gt;&lt;p&gt;Every database driver documentation says &amp;ldquo;deadlocks happen, retry the transaction.&amp;rdquo; That&amp;rsquo;s true. It&amp;rsquo;s also incomplete. The dangers are subtle enough that a naive retry loop becomes part of the problem:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Retries without backoff make the cycle worse.&lt;/strong&gt; The condition that caused the deadlock (contention on a hot key set) is still in effect when the retry runs. A tight retry loop turns one deadlock into a thundering herd: all victims retry simultaneously, all hit the same contention, all deadlock again. Use exponential backoff with full jitter, capped at a few hundred milliseconds.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Retries mask lock-ordering bugs.&lt;/strong&gt; If an application deadlocks 10x/minute but retries successfully, the operator sees no failures, but the underlying transactions are doing up to 20x the work (original + retry). The deadlock rate itself is a metric worth tracking, not just the post-retry error rate. When the rate grows, the fix is diagnosing the pattern, not tuning the retry limit.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Retries aren&amp;rsquo;t always safe.&lt;/strong&gt; A transaction that sent an external notification, wrote to a message queue, or called a non-idempotent HTTP endpoint before the deadlock can&amp;rsquo;t be blindly retried; the external side effect already happened. Retries belong on database-only transactions, or on transactions where the external calls are idempotent and tolerant of duplicate execution. The boundary is architectural, not a library setting.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Retries need a budget.&lt;/strong&gt; If a transaction can&amp;rsquo;t complete after ~3 attempts, the problem is no longer transient contention. It&amp;rsquo;s either a systemic hot spot or a bug that retries will never resolve. Escalate (alert, fail the request, enqueue for manual review), don&amp;rsquo;t loop forever.&lt;/p&gt;
&lt;p&gt;The retry pattern that works in production:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;span class="lnt"&gt;9
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;attempt&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="nb"&gt;range&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;try&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;with&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;transaction&lt;/span&gt;&lt;span class="p"&gt;():&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;do_work&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;return&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="k"&gt;except&lt;/span&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;DeadlockError&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;SerializationFailure&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;e&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;metrics&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;increment&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;db.retry&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;tags&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;error&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;e&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;code&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;time&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sleep&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;uniform&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="mf"&gt;0.1&lt;/span&gt; &lt;span class="o"&gt;*&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="o"&gt;**&lt;/span&gt;&lt;span class="n"&gt;attempt&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;raise&lt;/span&gt; &lt;span class="n"&gt;RetryBudgetExhausted&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Three attempts, exponential backoff up to ~400ms, metrics emitted on every retry, hard failure past the budget. The metric matters as much as the retry - without it, the team never learns which transactions are retrying frequently and why.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Idempotency is a transaction-shape property&lt;/strong&gt;
 &lt;div&gt;A transaction is safe to retry iff re-running it produces the same observable state. That includes downstream side effects. A transaction that writes to a table AND sends a webhook is not safe to retry even if both operations are internally correct: the second attempt sends a duplicate webhook. The fix is the outbox pattern: write the webhook-send intent to a table in the same transaction, then have a separate worker process the outbox with its own idempotency guarantees. This is a non-negotiable part of building deadlock-retry-safe systems at scale.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="sort-writes-to-lock-in-one-direction"&gt;Sort writes to lock in one direction
&lt;/h2&gt;&lt;p&gt;&lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-1-the-patterns/#the-canonical-lock-ordering-deadlock" &gt;Part 1&amp;rsquo;s lock-ordering deadlock&lt;/a&gt; (two transactions updating the same set of rows in opposite orders) is the single most common production pattern and the one with the highest-leverage fix. If every code path that writes to a set of tables acquires locks in the same order, the wait-for graph literally cannot form a cycle on those rows. The engine still takes the locks, still holds them for the duration of the transaction, but the second transaction waits cleanly for the first instead of grabbing a lock the first will need.&lt;/p&gt;
&lt;p&gt;The rule is: sort the rows by a stable key (usually the primary key) in application code, before any SQL is issued. Lock acquisition order in both engines is determined by the order the engine processes rows, which for most write patterns is the order the application submitted them. Sort once up front, and N workers all doing the same thing can&amp;rsquo;t cycle because they all approach the row set from the same end.&lt;/p&gt;
&lt;p&gt;The three batch shapes that matter in practice, and where the ordering actually happens:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;1. Loop of per-row UPDATEs.&lt;/strong&gt; The classic batch worker:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Sort in the application; the iteration order IS the lock acquisition order.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;UPDATE accounts SET balance = balance + &lt;/span&gt;&lt;span class="si"&gt;%s&lt;/span&gt;&lt;span class="s2"&gt; WHERE id = &lt;/span&gt;&lt;span class="si"&gt;%s&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Each UPDATE locks its target row at execution time; the loop order determines acquisition order. No SQL-level &lt;code&gt;ORDER BY&lt;/code&gt; involved; the fix lives in the &lt;code&gt;.sort()&lt;/code&gt; call.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;2. Bulk UPSERT.&lt;/strong&gt; &lt;code&gt;INSERT ... ON DUPLICATE KEY UPDATE&lt;/code&gt; (MySQL) or &lt;code&gt;INSERT ... ON CONFLICT&lt;/code&gt; (PostgreSQL):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Sort rows by the unique key BEFORE building the batch.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;sort&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;key&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="k"&gt;lambda&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;execute_values&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;INSERT INTO accounts (id, balance) VALUES &lt;/span&gt;&lt;span class="si"&gt;%s&lt;/span&gt;&lt;span class="s2"&gt; &amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;ON CONFLICT (id) DO UPDATE SET balance = accounts.balance + EXCLUDED.balance&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;[(&lt;/span&gt;&lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt; &lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;r&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The engine processes the VALUES list in order and acquires locks as it goes. Two concurrent batches sorted by the same key approach the key space from the same end; without the sort, one batch might process &lt;code&gt;(1, 2, 3)&lt;/code&gt; while another processes &lt;code&gt;(3, 2, 1)&lt;/code&gt; and they deadlock on the middle row. This is the exact shape of the bulk-UPSERT deadlocks called out in &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-1-the-patterns/#unique-index-deadlocks-are-a-category-of-their-own" &gt;Part 1&amp;rsquo;s unique-index section&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;3. Small multi-row transactions.&lt;/strong&gt; The canonical bank transfer:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Always update the lower id first, regardless of transfer direction.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;For 2–3 rows with per-row different values, the application computes &lt;code&gt;sorted((X, Y))&lt;/code&gt; and issues UPDATEs in that order. Same principle as the batch case, just smaller.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Where &lt;code&gt;SELECT ... FOR UPDATE ORDER BY&lt;/code&gt; actually earns its keep.&lt;/strong&gt; Most batches don&amp;rsquo;t need it; they control lock order through the submission order. The one shape where it&amp;rsquo;s the right answer is a single UPDATE statement over a derived table where the engine decides scan order and you can&amp;rsquo;t control it from outside:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &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="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&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="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&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="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="mi"&gt;25&lt;/span&gt;&lt;span class="p"&gt;))&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Here, sorting the VALUES list in application code doesn&amp;rsquo;t reliably control lock order; the planner picks the scan. A &lt;code&gt;SELECT id FROM accounts WHERE id IN (...) ORDER BY id FOR UPDATE&lt;/code&gt; up front pre-acquires locks in deterministic order before the UPDATE runs. Or refactor into shape 1 or 2.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;ORDER BY controls result order, not scan order&lt;/strong&gt;
 &lt;div&gt;&lt;code&gt;ORDER BY&lt;/code&gt; on a &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; controls the result ordering, but lock acquisition happens during the scan. With a primary-key or unique-index predicate (&lt;code&gt;WHERE id IN (...)&lt;/code&gt; on a PK), the planner does ordered index lookups and locks land in ORDER BY order in practice. For non-indexed predicates or range scans on non-unique columns, the planner may scan in a different order and sort results afterward; locks get acquired in scan order. Verify with &lt;code&gt;EXPLAIN&lt;/code&gt; before relying on this pattern against non-PK predicates. Also: MySQL&amp;rsquo;s &lt;code&gt;UPDATE ... ORDER BY&lt;/code&gt; syntax applies one SET clause to all matching rows; it doesn&amp;rsquo;t help when rows need different values.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;This sounds trivial. It almost never is in practice; the ordering has to hold across every code path that writes to the same tables: the main request handler, backfill scripts, admin scripts, scheduled jobs, ORM bulk-save methods, and whatever migration scripts run during releases. One path that writes in a different order is enough to reopen the cycle. The durable fix is encoding the order in the access layer so individual query sites can&amp;rsquo;t diverge from it:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;A repository function that always sorts by PK before writing.&lt;/li&gt;
&lt;li&gt;A stored procedure or database function that owns the multi-row write.&lt;/li&gt;
&lt;li&gt;A service method with the ordering baked in, and a lint rule or review check forbidding direct table writes from elsewhere.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Two places this invariant breaks without anyone noticing:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ORM bulk-save methods.&lt;/strong&gt; ORMs hide whether they process in input order or reorder internally. Django&amp;rsquo;s &lt;code&gt;Model.objects.bulk_update&lt;/code&gt;, SQLAlchemy&amp;rsquo;s &lt;code&gt;bulk_update_mappings&lt;/code&gt;, ActiveRecord&amp;rsquo;s &lt;code&gt;upsert_all&lt;/code&gt;, Hibernate&amp;rsquo;s batch inserts; some process in input order, some sort by PK internally, some chunk before doing either. If you can&amp;rsquo;t tell from docs, test it: two concurrent bulk-saves with opposing-ordered input lists will either deadlock (proving input order matters) or not (proving the ORM sorts internally). Either way, sorting the input collection before handing it to the ORM is cheap insurance.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Batches sourced from a &lt;code&gt;SELECT&lt;/code&gt; in the same transaction.&lt;/strong&gt; A common pattern: &amp;ldquo;grab N pending rows, process them.&amp;rdquo; If the feeder SELECT doesn&amp;rsquo;t have an &lt;code&gt;ORDER BY&lt;/code&gt;, rows come back in scan order, non-deterministic across workers, which reopens the cycle. The fix is an explicit &lt;code&gt;ORDER BY&lt;/code&gt; on the feeder query, not in the subsequent loop:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Bad: scan order feeds the loop.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;SELECT id, delta FROM pending WHERE status = &amp;#39;ready&amp;#39; LIMIT 100&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="c1"&gt;# Whatever the scan produced; varies across workers.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;...&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Good: deterministic order, same across every worker.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;rows&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;SELECT id, delta FROM pending WHERE status = &amp;#39;ready&amp;#39; ORDER BY id LIMIT 100&amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;for&lt;/span&gt; &lt;span class="n"&gt;row&lt;/span&gt; &lt;span class="ow"&gt;in&lt;/span&gt; &lt;span class="n"&gt;rows&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="o"&gt;...&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The lint-rule angle matters more than it sounds. Deadlock-ordering violations are almost impossible to catch in code review - two PRs that each look correct in isolation can introduce inconsistent ordering when combined. The check that actually works is structural: no direct writes to tables X, Y from anywhere except the repository. Once that invariant is enforced, the ordering invariant follows.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Multi-table transactions need the same rule applied to table order.&lt;/strong&gt; A transaction that updates &lt;code&gt;users&lt;/code&gt; then &lt;code&gt;orders&lt;/code&gt; in one code path, and &lt;code&gt;orders&lt;/code&gt; then &lt;code&gt;users&lt;/code&gt; in another, can deadlock through the FK chain even with per-table row ordering. The rule generalizes: sort rows within a table, and sort tables within a transaction, by a stable convention the whole codebase agrees on (alphabetical is fine; just pick one).&lt;/p&gt;
&lt;h2 id="weigh-the-isolation-level-trade-offs"&gt;Weigh the isolation-level trade-offs
&lt;/h2&gt;&lt;p&gt;The isolation level a workload runs under determines which deadlock categories even apply. Most MySQL deadlock incidents stem from &lt;code&gt;REPEATABLE READ&lt;/code&gt;&amp;rsquo;s gap locks, a category that doesn&amp;rsquo;t exist on PostgreSQL or on MySQL at &lt;code&gt;READ COMMITTED&lt;/code&gt;. Changing the isolation level is the single highest-leverage tuning lever, and also the one with the most potential to quietly break application correctness.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Dropping MySQL from &lt;code&gt;REPEATABLE READ&lt;/code&gt; to &lt;code&gt;READ COMMITTED&lt;/code&gt;.&lt;/strong&gt; Under &lt;code&gt;READ COMMITTED&lt;/code&gt;, InnoDB still takes row locks but skips most gap locks (they exist only for unique-key and FK enforcement on inserts). Most OLTP workloads don&amp;rsquo;t need &lt;code&gt;REPEATABLE READ&lt;/code&gt;&amp;rsquo;s range-consistency guarantee. Most application code was designed around &lt;code&gt;READ COMMITTED&lt;/code&gt; semantics anyway, because that&amp;rsquo;s what PostgreSQL and SQL Server default to. Teams migrating to &lt;code&gt;READ COMMITTED&lt;/code&gt; typically see deadlock rates drop by an order of magnitude with no functional change.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Avoiding range locks on write paths.&lt;/strong&gt; Independent of isolation level, replacing &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; scans over ranges with point lookups by primary key removes the gap-lock surface entirely on the statements that do it. If a write path doesn&amp;rsquo;t need to lock &amp;ldquo;all orders for customer 5,&amp;rdquo; locking just the specific row it&amp;rsquo;s about to update is both faster and safer.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;FK shared locks are shorter-lived under &lt;code&gt;READ COMMITTED&lt;/code&gt;.&lt;/strong&gt; The &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-1-the-patterns/#foreign-keys-take-shared-locks-you-didnt-ask-for" &gt;foreign-key shared-lock pattern from Part 1&lt;/a&gt; (high-write child tables concentrating shared locks on hot parent rows) has a narrower window under &lt;code&gt;READ COMMITTED&lt;/code&gt; simply because the lock lifespan is tied to the statement rather than the transaction. The cycle potential is still there, but the wait window is smaller.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Isolation change is a behavior change, not a tuning knob&lt;/strong&gt;
 &lt;div&gt;&lt;code&gt;READ COMMITTED&lt;/code&gt; eliminates most gap locks but also changes the visibility semantics of long-running transactions. Any code that relied on re-reading a row and getting the same result (transfer logic, inventory deductions, financial calculations) has to be re-examined. The safe migration is application-by-application, not database-wide. Run it in staging under production-like load and watch for subtle correctness regressions: &amp;ldquo;phantom&amp;rdquo; rows appearing inside a transaction that used to see a stable snapshot, inventory counts that shift mid-transaction, calculations that no longer match because an underlying row changed between reads.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;&lt;strong&gt;Session-scoped change as a migration path.&lt;/strong&gt; Both engines let you set isolation level per session or per transaction (&lt;code&gt;SET SESSION TRANSACTION ISOLATION LEVEL READ COMMITTED&lt;/code&gt; in MySQL, &lt;code&gt;SET TRANSACTION ISOLATION LEVEL READ COMMITTED&lt;/code&gt; per transaction in PostgreSQL). The usual migration pattern is to start with the most contended code paths, move them to session-scoped &lt;code&gt;READ COMMITTED&lt;/code&gt;, monitor for regressions, then expand the scope. A global flip from &lt;code&gt;REPEATABLE READ&lt;/code&gt; to &lt;code&gt;READ COMMITTED&lt;/code&gt; on a large, stable MySQL deployment is rarely the right first step.&lt;/p&gt;
&lt;h2 id="isolate-hot-rows-off-the-contention-path"&gt;Isolate hot rows off the contention path
&lt;/h2&gt;&lt;p&gt;When the top N deadlocks on a production system concentrate on a small set of rows (a counter, a config row, an &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; source of truth), retries don&amp;rsquo;t converge. Every retry hits the same row, takes the same lock, cycles with the same peers. The fix is removing the row from the hot path, not tuning the retry layer.&lt;/p&gt;
&lt;p&gt;Three patterns that work:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Counter tables with sharding&lt;/strong&gt;, for extreme write-hot counters only. Reach for this only when the counter is taking thousands of writes per second against a single row and the simpler options below aren&amp;rsquo;t viable. For anything less, the queue pattern below or an external store (Redis atomic &lt;code&gt;INCR&lt;/code&gt;, a time-series DB) is almost always the better answer: less complexity, no schema overhead, no sum-across-rows read cost. Sharded counters are the specialized escalation, not the default.&lt;/p&gt;
&lt;p&gt;When it does fit the workload: N physical shards per logical counter, keyed on a compact integer &lt;code&gt;(counter_id, shard_id)&lt;/code&gt; composite. Application code picks the shard. Keeping the random choice out of SQL makes it portable across engines and testable independently:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;counter_shards&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;counter_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- FK to a counters metadata table if you need names
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;shard_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;SMALLINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- 0..N-1, fixed per-counter
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DEFAULT&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;counter_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;shard_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Seed the shards once per counter (e.g., when the counter is created):
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;counter_shards&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;counter_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;shard_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;value&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&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="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="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&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="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&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="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&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="mi"&gt;0&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;4&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="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&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="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;6&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="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;8&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="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;9&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="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&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="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;11&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;12&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="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;13&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="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;14&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="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;15&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&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-python" data-lang="python"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Increment: application picks the shard. Portable, cheap, no SQL-side RAND().&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;shard&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;random&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;randrange&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;16&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;UPDATE counter_shards SET value = value + 1 &amp;#34;&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;WHERE counter_id = &lt;/span&gt;&lt;span class="si"&gt;%s&lt;/span&gt;&lt;span class="s2"&gt; AND shard_id = &lt;/span&gt;&lt;span class="si"&gt;%s&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;shard&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;# Read: sum across shards.&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="o"&gt;.&lt;/span&gt;&lt;span class="n"&gt;execute&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;SELECT COALESCE(SUM(value), 0) FROM counter_shards WHERE counter_id = &lt;/span&gt;&lt;span class="si"&gt;%s&lt;/span&gt;&lt;span class="s2"&gt;&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,),&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;16 shards turn one hot row into 16 warm rows. The contention surface scales with shard count. The read cost is one &lt;code&gt;SUM&lt;/code&gt; across N rows instead of a single-row &lt;code&gt;SELECT&lt;/code&gt;, usually acceptable for counter use cases; if not, cache the aggregate.&lt;/p&gt;
&lt;p&gt;A common refinement is deriving the shard deterministically from the connection or worker ID (e.g., &lt;code&gt;connection_id % 16&lt;/code&gt;) so each worker consistently hits the same shard. That improves InnoDB buffer-pool locality and reduces cross-shard interference, at the cost of slightly less even distribution if worker counts aren&amp;rsquo;t balanced.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Advisory locks for app-level serialization.&lt;/strong&gt; Both MySQL and PostgreSQL support advisory locks: named locks that exist outside the table model and don&amp;rsquo;t take row locks. For operations that need to be serialized at the application level (leader election, rate limiting, config migration), advisory locks are dramatically cheaper than row locks and can&amp;rsquo;t participate in a table-lock cycle:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- PostgreSQL: advisory lock keyed by a bigint.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pg_advisory_xact_lock&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;hashtext&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;refresh_cache:customer_42&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;));&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Lock released at transaction end. Only one worker per key runs at a time.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- MySQL equivalent:
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;GET_LOCK&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;refresh_cache:customer_42&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Returns 1 if acquired, 0 on timeout. Must explicitly RELEASE_LOCK.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The caveats: advisory locks are application-layer discipline; they don&amp;rsquo;t enforce anything the database checks. Use them where the application chooses to serialize, not where correctness requires it.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Queue patterns instead of direct updates.&lt;/strong&gt; For counter-like workloads, write intents to an append-only table and aggregate periodically:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;span class="lnt"&gt;9
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;counter_events&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;counter_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &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="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;?&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;());&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- No contention: every insert creates a new row.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Periodic aggregation job:
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;counter_totals&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;counter_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;counter_key&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;delta&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;counter_events&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;processed&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FALSE&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;counter_key&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;CONFLICT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;counter_key&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;counter_totals&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;EXCLUDED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Trades real-time accuracy for throughput. The right trade-off for page-view counters, metric accumulation, any workload where eventual consistency is acceptable.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Hot parent rows behind FK chains.&lt;/strong&gt; &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-1-the-patterns/#foreign-keys-take-shared-locks-you-didnt-ask-for" &gt;Part 1&lt;/a&gt; described how a high-write child table concentrates shared locks on a hot parent row, and how any exclusive-lock request on that parent (a name update, a soft-delete, a trigger-driven counter update) becomes a contention point. Two levers that work:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Move high-frequency parent-row updates to a side table.&lt;/strong&gt; The &lt;code&gt;last_activity_at&lt;/code&gt; timestamp, the cached counter, the &lt;code&gt;updated_at&lt;/code&gt; that a trigger bumps on every child insert; none of these need to live on the parent table. Moving them to &lt;code&gt;customer_activity(customer_id, last_seen_at)&lt;/code&gt; or &lt;code&gt;customer_counters(customer_id, order_count)&lt;/code&gt; eliminates the exclusive-lock contention entirely. The parent row stops changing on hot paths, the shared locks from FK checks coexist fine, and the cycle potential disappears.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Narrow the FK scope where integrity can tolerate it.&lt;/strong&gt; Not every child table needs an enforced FK to every parent. Logs, events, and audit tables are often the biggest offenders, and often have the least need for strict integrity (an orphaned log row is rarely a correctness problem). Dropping the FK removes the shared-lock dependency entirely. This trades integrity for throughput, a decision that belongs with the team owning the data, not a default.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Under &lt;code&gt;READ COMMITTED&lt;/code&gt;, both levers matter less because the FK shared locks release at statement end rather than transaction end. A workload that runs on &lt;code&gt;REPEATABLE READ&lt;/code&gt; and can&amp;rsquo;t change isolation level (because of application semantics) gets the most benefit from these two fixes.&lt;/p&gt;
&lt;h2 id="shorten-long-running-transactions"&gt;Shorten long-running transactions
&lt;/h2&gt;&lt;p&gt;The longer a transaction holds locks, the wider the window for a cycle. Two patterns recur in production:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Application-layer long transactions.&lt;/strong&gt; A transaction that opens at request start, makes several queries, calls an external API, then commits. The external call is where the transaction actually spends its time: seconds of open transaction holding row locks the whole time. Every concurrent transaction that touches those rows waits. Deadlock probability scales with transaction duration. The fix is the inverse of the outbox pattern: do the external call outside the transaction, passing in any needed IDs.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Idle-in-transaction sessions.&lt;/strong&gt; A session that runs &lt;code&gt;BEGIN&lt;/code&gt;, some writes, then stalls; idle but not committed. In PostgreSQL, this blocks vacuum on touched tables, bloats MVCC, and holds locks indefinitely. &lt;code&gt;pg_stat_activity&lt;/code&gt; shows &lt;code&gt;state = 'idle in transaction'&lt;/code&gt;. MySQL&amp;rsquo;s equivalent is a thread with an open transaction and no current query.&lt;/p&gt;
&lt;p&gt;PostgreSQL has a first-class timeout for this; MySQL does not:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- PostgreSQL: kill idle-in-transaction sessions after 5 minutes.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- (Units required; bare integer would be interpreted as milliseconds.)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;idle_in_transaction_session_timeout&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;5min&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;MySQL has no direct equivalent. &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_wait_timeout" target="_blank" rel="noopener"
 &gt;&lt;code&gt;wait_timeout&lt;/code&gt;&lt;/a&gt; and &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/server-system-variables.html#sysvar_interactive_timeout" target="_blank" rel="noopener"
 &gt;&lt;code&gt;interactive_timeout&lt;/code&gt;&lt;/a&gt; govern idle connections, not sessions idle inside an open transaction. A connection that did &lt;code&gt;BEGIN&lt;/code&gt; then stopped sending queries will hold its locks until the connection drops or the client commits. The production workaround is either a watchdog script (e.g., Percona&amp;rsquo;s &lt;code&gt;pt-kill&lt;/code&gt;) that polls &lt;code&gt;information_schema.innodb_trx&lt;/code&gt; and terminates transactions exceeding a duration threshold, or a connection pool with per-connection transaction lifetimes. Connection pools that acquire a connection, start a transaction, then return the connection to the pool without committing (rare but real) will produce sessions that live indefinitely otherwise.&lt;/p&gt;
&lt;p&gt;Finding long-running transactions:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- PostgreSQL
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pid&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;usename&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;xact_start&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;xact_start&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;query&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pg_stat_activity&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;xact_start&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;now&lt;/span&gt;&lt;span class="p"&gt;()&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;xact_start&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INTERVAL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;30 seconds&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;duration&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- MySQL
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;trx_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;trx_started&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;trx_mysql_thread_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;trx_rows_locked&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;trx_query&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &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;innodb_trx&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TIMESTAMPDIFF&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SECOND&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;trx_started&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NOW&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;30&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Alerting on any transaction exceeding 30s in an OLTP workload catches most of the long-transaction-induced deadlocks before they produce incidents.&lt;/p&gt;
&lt;h2 id="audit-triggers-and-cascades-for-hidden-locks"&gt;Audit triggers and cascades for hidden locks
&lt;/h2&gt;&lt;p&gt;A trigger that updates a second table on every write to the first adds an edge to the wait-for graph that isn&amp;rsquo;t visible in the original query. &lt;code&gt;ON DELETE CASCADE&lt;/code&gt; foreign keys behave similarly - one delete can take locks on every child row in the cascade, and if the cascade order differs between two concurrent deletes, they can deadlock through tables neither statement directly referenced.&lt;/p&gt;
&lt;p&gt;This is the origin of the &amp;ldquo;why is my &lt;code&gt;DELETE FROM users&lt;/code&gt; deadlocking against an &lt;code&gt;INSERT INTO events&lt;/code&gt;?&amp;rdquo; question. The &lt;code&gt;DELETE&lt;/code&gt; triggered a cascade to &lt;code&gt;user_preferences&lt;/code&gt;, which had a trigger that updated a counter in &lt;code&gt;tenants&lt;/code&gt;, which was locked by the &lt;code&gt;INSERT&lt;/code&gt;. Four tables in the cycle, two in the application&amp;rsquo;s explicit query, zero mention of the other two in any log entry until someone reads the DDL.&lt;/p&gt;
&lt;p&gt;The operational pattern: when a deadlock log mentions a table the application&amp;rsquo;s code doesn&amp;rsquo;t explicitly reference, check (1) FK cascades on the tables that are in the query, (2) triggers on those tables, (3) generated columns that fire on update. All three are non-obvious lock sources, all three are fixable, but only after they&amp;rsquo;re identified.&lt;/p&gt;
&lt;h2 id="set-innodb_autoinc_lock_mode-deliberately"&gt;Set &lt;code&gt;innodb_autoinc_lock_mode&lt;/code&gt; deliberately
&lt;/h2&gt;&lt;p&gt;MySQL InnoDB&amp;rsquo;s &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; column has its own lock, historically a source of contention and occasional deadlock. The &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/innodb-parameters.html#sysvar_innodb_autoinc_lock_mode" target="_blank" rel="noopener"
 &gt;&lt;code&gt;innodb_autoinc_lock_mode&lt;/code&gt;&lt;/a&gt; parameter controls the behavior:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Mode 0 (traditional).&lt;/strong&gt; Table-level AUTO-INC lock held for the duration of the statement. Serialized across inserts. Safe for statement-based replication, terrible for concurrency.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mode 1 (consecutive).&lt;/strong&gt; A lighter lock for simple inserts (single-row or known-row-count), and the traditional table lock for bulk inserts (&lt;code&gt;INSERT ... SELECT&lt;/code&gt;). Was the default in 5.7.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Mode 2 (interleaved).&lt;/strong&gt; No AUTO-INC table lock; IDs are assigned per-row as needed, possibly interleaved across concurrent statements. Default in MySQL 8.0. Fastest, and correct for row-based replication (which is also the 8.0 default).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The mode-2 default in 8.0 eliminated a substantial source of historical deadlocks and contention. Bulk inserts that used to serialize on the AUTO-INC lock now proceed in parallel. If you&amp;rsquo;re migrating from 5.7 to 8.0, this is a free win. If you&amp;rsquo;re still on &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/replication-options-binary-log.html#sysvar_binlog_format" target="_blank" rel="noopener"
 &gt;&lt;code&gt;binlog_format&lt;/code&gt;&lt;/a&gt;&lt;code&gt; = STATEMENT&lt;/code&gt; (uncommon but not unheard of in legacy deployments), you cannot safely run mode 2; the replica may generate different IDs than the source, corrupting the data. Switch to &lt;code&gt;binlog_format = ROW&lt;/code&gt; first, then adopt mode 2.&lt;/p&gt;
&lt;h2 id="guard-ddl-migrations-with-lock_timeout"&gt;Guard DDL migrations with &lt;code&gt;lock_timeout&lt;/code&gt;
&lt;/h2&gt;&lt;p&gt;Online schema change isn&amp;rsquo;t deadlock-prone in the classical sense, but it interacts with deadlocks in a specific operational way: DDL takes heavy locks that queue behind ongoing DML, and while the DDL waits, every subsequent query on that table queues behind the DDL. In PostgreSQL, a DDL taking &lt;code&gt;ACCESS EXCLUSIVE&lt;/code&gt; that waits for an existing long-running &lt;code&gt;SELECT&lt;/code&gt; will cause every new &lt;code&gt;SELECT&lt;/code&gt; to wait behind the DDL. The system grinds to a halt, and application logs fill with timeout errors that look like deadlocks but aren&amp;rsquo;t. It&amp;rsquo;s a queue, not a cycle.&lt;/p&gt;
&lt;p&gt;The standard prevention idiom for PostgreSQL migrations:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;lock_timeout&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2s&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ADD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COLUMN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;notes&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;If the &lt;code&gt;ALTER&lt;/code&gt; can&amp;rsquo;t acquire its lock in 2 seconds, it fails instead of queueing. The migration tool catches the error and retries with backoff. This prevents the queue-behind-DDL outage entirely - the cost is that some migrations need multiple attempts to land, which is almost always the right trade-off.&lt;/p&gt;
&lt;p&gt;MySQL&amp;rsquo;s equivalent tooling is &lt;code&gt;pt-online-schema-change&lt;/code&gt; (Percona) and &lt;code&gt;gh-ost&lt;/code&gt; (GitHub). Both create a copy of the table, stream writes to both via trigger or binlog, and swap at the end. They run concurrent DML against the original and the copy, so they inflate deadlock rates during the migration window: not because the tool is buggy, but because there are now more transactions touching the same rows. The operational practice: run migrations at low-traffic windows, watch the deadlock counter during the run (not just replica lag), and have a rollback path ready.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;DDL inside transactions is engine-dependent&lt;/strong&gt;
 &lt;div&gt;PostgreSQL supports transactional DDL: &lt;code&gt;BEGIN; ALTER TABLE ...; COMMIT;&lt;/code&gt; is atomic. MySQL does not; every DDL statement implicitly commits the current transaction. A migration script that assumes it can roll back mid-migration works on PostgreSQL and silently half-applies on MySQL. Know which engine you&amp;rsquo;re writing migrations for.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="reach-for-nowait-and-skip-locked"&gt;Reach for &lt;code&gt;NOWAIT&lt;/code&gt; and &lt;code&gt;SKIP LOCKED&lt;/code&gt;
&lt;/h2&gt;&lt;p&gt;Both engines support two SQL-level concurrency primitives that remove the need for application-layer deadlock handling in specific patterns:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;SELECT ... FOR UPDATE NOWAIT&lt;/code&gt;.&lt;/strong&gt; If the row is locked by another transaction, fail immediately with an error instead of waiting. Useful for user-facing paths where &amp;ldquo;I can&amp;rsquo;t get this resource right now&amp;rdquo; is a better UX than &amp;ldquo;wait 500ms and maybe deadlock anyway.&amp;rdquo; Also useful for detecting lock contention synthetically in tests.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;&lt;code&gt;SELECT ... FOR UPDATE SKIP LOCKED&lt;/code&gt;.&lt;/strong&gt; If rows are locked by another transaction, skip them and return only rows the current transaction can lock. Transforms a contended queue-processor pattern into a lock-free one: N workers each grab a different set of rows, zero contention, zero deadlocks.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Queue processor: deadlock-free, contention-free.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;jobs&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;pending&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LIMIT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FOR&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SKIP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;LOCKED&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Fast-fail acquisition: don&amp;#39;t wait, fail now.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;leader_election&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;resource_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;cache-refresh&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FOR&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NOWAIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;SKIP LOCKED&lt;/code&gt; arrived in PostgreSQL 9.5 and MySQL 8.0. Before those versions, queue-processor patterns required either advisory locks or application-level coordination (Redis, Zookeeper). Post-&lt;code&gt;SKIP LOCKED&lt;/code&gt;, they can live entirely in the database with a single primitive. For any workload where workers pull from a shared queue, this is the pattern - not retry loops on &lt;code&gt;FOR UPDATE&lt;/code&gt;.&lt;/p&gt;
&lt;h2 id="monitor-the-metrics-that-catch-regressions"&gt;Monitor the metrics that catch regressions
&lt;/h2&gt;&lt;p&gt;The single most useful metric is deadlock rate over time. Not error rate, not retry rate; the raw count of deadlocks per minute or per thousand transactions. A workload with 0.1 deadlocks per thousand transactions is healthy; 10 per thousand is a paging threshold; 100 per thousand means retries aren&amp;rsquo;t converging and something is structurally wrong.&lt;/p&gt;
&lt;p&gt;For MySQL: there&amp;rsquo;s no &lt;code&gt;Innodb_deadlocks&lt;/code&gt; status variable. The correct source is &lt;code&gt;performance_schema.events_errors_summary_global_by_error&lt;/code&gt;, which is enabled by default:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Cumulative deadlock count (compare over time windows).
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;SUM_ERROR_RAISED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;deadlock_count&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;performance_schema&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;events_errors_summary_global_by_error&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ERROR_NAME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;ER_LOCK_DEADLOCK&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Lock-wait activity (useful for adjacent contention, NOT a deadlock counter):
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SHOW&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;GLOBAL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;STATUS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LIKE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Innodb_row_lock_waits&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Plus the error log, searchable for &amp;#34;LATEST DETECTED DEADLOCK&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- once innodb_print_all_deadlocks=ON.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;Innodb_row_lock_waits&lt;/code&gt; is commonly misread as a deadlock counter - &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/server-status-variables.html#statvar_Innodb_row_lock_waits" target="_blank" rel="noopener"
 &gt;the manual&lt;/a&gt; defines it as &amp;ldquo;the number of times operations on InnoDB tables had to wait for a row lock,&amp;rdquo; which is contention in general. Pair it with the &lt;code&gt;events_errors_summary&lt;/code&gt; query, not in place of it.&lt;/p&gt;
&lt;p&gt;For PostgreSQL:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- pg_stat_database exposes per-database deadlock counter.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;datname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;deadlocks&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;xact_commit&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;xact_rollback&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pg_stat_database&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;datname&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;current_database&lt;/span&gt;&lt;span class="p"&gt;();&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Scrape both into Prometheus (mysqld_exporter and postgres_exporter both expose these), compute the rate, alert on sustained rises. Pair the deadlock rate with a &lt;em&gt;retry rate&lt;/em&gt; from the application layer - a spike in one without the other means either the retry logic is broken or the workload shape changed. A spike in both means a real regression.&lt;/p&gt;
&lt;p&gt;Beyond the rate itself, the top-K pairs of statements involved in deadlocks (extracted from &lt;code&gt;innodb_print_all_deadlocks&lt;/code&gt; logs or PG&amp;rsquo;s deadlock log entries) identify exactly which code paths are fighting. This list rarely changes - the same two or three patterns account for most deadlocks in any given system. Fix those and the rate drops by an order of magnitude.&lt;/p&gt;
&lt;h2 id="the-mental-model-for-part-2"&gt;The mental model for Part 2
&lt;/h2&gt;&lt;p&gt;Part 1&amp;rsquo;s patterns answer why deadlocks happen. Part 2&amp;rsquo;s operations answer what to do about them, and the useful reframe is that the answer is almost never &amp;ldquo;tune the retry logic.&amp;rdquo; Retries are the recovery mechanism that keeps the application running while the actual fix lands. The actual fix is almost always one of:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Identify the pattern from the log. This is step zero; skipping it means you&amp;rsquo;re tuning blind.&lt;/li&gt;
&lt;li&gt;Enforce consistent lock ordering at the access-layer level. Highest-leverage fix for the lock-ordering pattern; deterministically eliminates the cycle rather than shrinking its window.&lt;/li&gt;
&lt;li&gt;Change the code path to use &lt;code&gt;SKIP LOCKED&lt;/code&gt; / &lt;code&gt;NOWAIT&lt;/code&gt; where the pattern matches (queue processors, resource acquisition).&lt;/li&gt;
&lt;li&gt;Isolate hot rows (counter tables, shards, advisory locks, queue patterns; move high-frequency parent-row updates to side tables).&lt;/li&gt;
&lt;li&gt;Shorten transactions. Move external calls out, enforce idle-transaction timeouts.&lt;/li&gt;
&lt;li&gt;Drop isolation level where the workload allows it; session-scoped first, global only after regression testing. Eliminates the gap-lock category on MySQL.&lt;/li&gt;
&lt;li&gt;Remove cascades/triggers from the hot path when they&amp;rsquo;re the hidden lock source.&lt;/li&gt;
&lt;li&gt;Handle &lt;code&gt;SERIALIZABLE&lt;/code&gt;&amp;rsquo;s 40001 as a normal event if you&amp;rsquo;re on &lt;code&gt;SERIALIZABLE&lt;/code&gt;, and don&amp;rsquo;t confuse it with 40P01.&lt;/li&gt;
&lt;li&gt;Plan DDL windows with &lt;code&gt;lock_timeout&lt;/code&gt; and watch the deadlock counter through the migration.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Retries let the application survive while the fix is in flight. Monitoring tells you which fix to prioritize. Each of the above removes a category from the workload entirely. The goal is a system where the few remaining deadlocks are rare enough that the retry layer handles them invisibly and the team&amp;rsquo;s attention can go elsewhere. Not zero (that&amp;rsquo;s a theoretical fiction at realistic concurrency), but managed.&lt;/p&gt;</description></item><item><title>Database Deadlocks, Part 1: The Patterns</title><link>https://explainanalyze.com/p/database-deadlocks-part-1-the-patterns/</link><pubDate>Thu, 13 Feb 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/database-deadlocks-part-1-the-patterns/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Database Deadlocks, Part 1: The Patterns" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;A deadlock is two transactions each holding a lock the other needs, caught in a cycle the engine breaks by killing one. The patterns are finite and repeatable: inconsistent lock ordering across workers, InnoDB gap locks under &lt;code&gt;REPEATABLE READ&lt;/code&gt;, foreign-key shared locks on hot parent rows, unique-index conflicts, index-scan lock amplification, and parallelism patterns that only surface on replicas or under worker-pool load. This post is the patterns. &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/" target="_blank" rel="noopener"
 &gt;Part 2&lt;/a&gt; covers diagnosis, retry architecture, and prevention.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Deadlocks occupy a strange place in production operations. They&amp;rsquo;re rare enough that most engineers haven&amp;rsquo;t thought hard about them, frequent enough in high-concurrency workloads to show up as paging incidents, and subtle enough that the first instinct (&amp;ldquo;just retry&amp;rdquo;) is right often enough to keep the root cause hidden. The transaction that got killed was syntactically perfect. The one that survived was too. The bug wasn&amp;rsquo;t in either statement; it was in the order the two transactions touched rows.&lt;/p&gt;
&lt;p&gt;That makes deadlocks harder to reason about than most database failures. The query text in the error log isn&amp;rsquo;t wrong. The lock it was waiting for isn&amp;rsquo;t held by a misbehaving process. The system is doing exactly what concurrency control says it should. The failure mode is the interaction between transactions, and those interactions are almost never visible from any single query.&lt;/p&gt;
&lt;p&gt;This post covers the patterns: the shapes deadlocks take and why each one exists. The &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/" &gt;companion post&lt;/a&gt; covers reading the deadlock log end-to-end, retry architecture, hot-row isolation, SERIALIZABLE&amp;rsquo;s serialization failures, DDL migration windows, and &lt;code&gt;NOWAIT&lt;/code&gt; / &lt;code&gt;SKIP LOCKED&lt;/code&gt; as prevention primitives.&lt;/p&gt;
&lt;h2 id="what-a-deadlock-actually-is"&gt;What a deadlock actually is
&lt;/h2&gt;&lt;p&gt;A deadlock is a cycle in the wait-for graph. Transaction A holds lock L1 and needs L2; transaction B holds L2 and needs L1. Neither can proceed. The only way out is to kill one: pick a victim, roll it back, release its locks, and let the other complete. Every modern relational database does this automatically, usually within hundreds of milliseconds.&lt;/p&gt;
&lt;p&gt;The three preconditions are always the same:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Two or more transactions hold locks.&lt;/li&gt;
&lt;li&gt;Each needs a lock the other holds.&lt;/li&gt;
&lt;li&gt;The locks can&amp;rsquo;t be acquired atomically (there&amp;rsquo;s no single &amp;ldquo;grab both or grab nothing&amp;rdquo; operation).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Remove any one and the deadlock can&amp;rsquo;t form. In practice, that&amp;rsquo;s the shape of every prevention strategy: reduce the number of locks held concurrently, reduce the duration they&amp;rsquo;re held, or make the acquisition order consistent across all code paths.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;Deadlock vs. lock wait timeout&lt;/strong&gt;
 &lt;div&gt;A deadlock is a cycle. A lock wait timeout is a long queue: transaction A is waiting for transaction B, which is waiting for something reasonable, which is taking too long. No cycle, no victim selection, just a timer expiring. Both produce errors that look similar in application logs, but they&amp;rsquo;re entirely different failure modes with different fixes. &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/innodb-parameters.html#sysvar_innodb_lock_wait_timeout" target="_blank" rel="noopener"
 &gt;&lt;code&gt;innodb_lock_wait_timeout&lt;/code&gt;&lt;/a&gt; (MySQL) and &lt;a class="link" href="https://www.postgresql.org/docs/current/runtime-config-client.html#GUC-LOCK-TIMEOUT" target="_blank" rel="noopener"
 &gt;&lt;code&gt;lock_timeout&lt;/code&gt;&lt;/a&gt; (PostgreSQL) govern the second. The deadlock detector is a separate mechanism that fires independently of those timers.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="the-canonical-lock-ordering-deadlock"&gt;The canonical lock-ordering deadlock
&lt;/h2&gt;&lt;p&gt;The single most common production deadlock is two transactions updating the same two rows in opposite orders:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Transaction A
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;100&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Transaction B (concurrent)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;accounts&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;balance&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;50&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;COMMIT&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;If A and B interleave such that A takes the row-level lock on row 1, B takes the row-level lock on row 2, and each then tries to grab the other row, the engine has a cycle. One gets killed.&lt;/p&gt;
&lt;pre class="mermaid" style="visibility:hidden"&gt;sequenceDiagram
 participant A as Transaction A
 participant R1 as Row id=1
 participant R2 as Row id=2
 participant B as Transaction B

 Note over A,B: t=0, both transactions begin
 A-&gt;&gt;R1: UPDATE (acquire X-lock)
 R1--&gt;&gt;A: granted
 B-&gt;&gt;R2: UPDATE (acquire X-lock)
 R2--&gt;&gt;B: granted

 Note over A,B: t=1, each reaches for the other's row
 A-&gt;&gt;R2: UPDATE (request X-lock)
 R2--xA: BLOCKED (held by B)
 B-&gt;&gt;R1: UPDATE (request X-lock)
 R1--xB: BLOCKED (held by A)

 Note over A,B: Wait-for graph has a cycle: A → B → A
 Note over A,B: Detector fires; victim: Transaction B
 B-&gt;&gt;B: ROLLBACK, release R2 lock
 R2--&gt;&gt;A: now granted
 A-&gt;&gt;A: COMMIT&lt;/pre&gt;&lt;p&gt;The key property is that neither transaction is wrong in isolation. Each acquires locks in an order that&amp;rsquo;s locally correct. The cycle forms in the global ordering across concurrent sessions, which no single query can see. That&amp;rsquo;s the defining shape of the pattern: correct code, in both cases, interacting at the transaction boundary. The fix is in how all code paths agree on an ordering, covered in &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/#consistent-lock-ordering-the-highest-leverage-fix" &gt;Part 2: Consistent lock ordering&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="innodb-gap-locks-turn-inserts-into-deadlock-sources"&gt;InnoDB gap locks turn inserts into deadlock sources
&lt;/h2&gt;&lt;p&gt;MySQL&amp;rsquo;s default isolation level is &lt;code&gt;REPEATABLE READ&lt;/code&gt;, and under that isolation level, InnoDB takes next-key locks: a row lock plus a gap lock on the range before it. The gap lock prevents other transactions from inserting into that range, which is how &lt;code&gt;REPEATABLE READ&lt;/code&gt; keeps range queries consistent across re-execution.&lt;/p&gt;
&lt;p&gt;The consequence: a &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; or an &lt;code&gt;UPDATE&lt;/code&gt; with a range predicate locks not just the matching rows, but the gaps between them. Two concurrent transactions that both try to insert into the same gap can deadlock without sharing a single row:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Table: orders(id BIGINT PK, customer_id BIGINT, amount_cents BIGINT)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Index on customer_id
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Existing rows: customer_id = 5 has orders with ids 100, 200, 300
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Transaction A
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FOR&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Takes next-key locks covering ids 100, 200, 300 AND the gaps between them,
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- AND the gap after 300 extending to the next customer_id.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Transaction B (concurrent)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;BEGIN&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;250&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;10000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Blocks: tries to insert into a gap A has locked.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Transaction A
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;150&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Deadlock if B has also started acquiring locks that A now needs.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Two transactions inserting into what looks like &amp;ldquo;different rows&amp;rdquo; can still cycle through gap locks. The failure mode is especially insidious because the &lt;code&gt;EXPLAIN&lt;/code&gt; plan doesn&amp;rsquo;t mention gaps - only rows - and the lock information in &lt;code&gt;SHOW ENGINE INNODB STATUS&lt;/code&gt; requires reading the next-key notation carefully.&lt;/p&gt;
&lt;p&gt;The category is peculiar to InnoDB under &lt;code&gt;REPEATABLE READ&lt;/code&gt;. PostgreSQL prevents phantom reads through MVCC snapshot isolation starting at its own &lt;code&gt;REPEATABLE READ&lt;/code&gt; level (stricter than the SQL standard requires) without any range-locking mechanism, so the whole class of gap-lock deadlocks doesn&amp;rsquo;t exist on PG, at any isolation level. Under &lt;code&gt;READ COMMITTED&lt;/code&gt; on MySQL, gap locks are disabled for most searches and index scans but retained for foreign-key and duplicate-key checking, which is the first lever most teams reach for once this pattern dominates their deadlocks, though it doesn&amp;rsquo;t eliminate the gap-lock category entirely. The isolation-level trade-off and the &amp;ldquo;avoid range locks on write paths&amp;rdquo; refactor both live in &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/#isolation-level-trade-offs" &gt;Part 2: Isolation-level trade-offs&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="unique-index-deadlocks-are-a-category-of-their-own"&gt;Unique-index deadlocks are a category of their own
&lt;/h2&gt;&lt;p&gt;The detailed patterns are covered in &lt;a class="link" href="https://explainanalyze.com/p/uniqueness-and-selectivity-the-two-numbers-that-drive-query-plans/#unique-indexes-also-concentrate-deadlock-pressure" &gt;Uniqueness and Selectivity&lt;/a&gt;, but the shape worth naming here: when InnoDB detects a duplicate-key error on an &lt;code&gt;INSERT&lt;/code&gt;, it acquires a shared lock on the conflicting index record before raising the error. Under &lt;code&gt;REPEATABLE READ&lt;/code&gt; that shared lock is next-key (record + gap). Under &lt;code&gt;READ COMMITTED&lt;/code&gt;, gap locks are mostly disabled, but duplicate-key checking is one of the documented exceptions where gap locking still occurs, so dropping isolation alone doesn&amp;rsquo;t eliminate the category. Two concurrent transactions inserting toward the same unique key end up holding shared locks and waiting for each other to release: a deadlock caused entirely by the uniqueness check, not by the rows the application thought it was writing.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;INSERT ... ON DUPLICATE KEY UPDATE&lt;/code&gt; behaves differently: on conflict it takes an exclusive lock instead of a shared one, because the statement is about to modify the row. This matters for reasoning about cycles. Two concurrent &lt;code&gt;ODKU&lt;/code&gt; statements contend on exclusive locks (mutually exclusive, one always waits), whereas two concurrent plain &lt;code&gt;INSERT&lt;/code&gt;s can both hold shared locks at once and then deadlock when either tries to upgrade. Blog posts and older documentation sometimes conflate the two; the locking rules are documented in the &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/innodb-locks-set.html" target="_blank" rel="noopener"
 &gt;MySQL reference: Locks Set by Different SQL Statements in InnoDB&lt;/a&gt;.&lt;/p&gt;
&lt;p&gt;The equivalent in PostgreSQL is less severe (the duplicate-key check doesn&amp;rsquo;t hold long-lived shared locks the same way) but &lt;code&gt;INSERT ... ON CONFLICT&lt;/code&gt; with multiple unique indexes can still produce deadlocks when batches touch overlapping keys in different orders. The shape is the same across engines: the uniqueness check itself is what forces the extra locking, and the cycle forms when two sessions approach the same key from different batches.&lt;/p&gt;
&lt;h2 id="foreign-keys-take-shared-locks-you-didnt-ask-for"&gt;Foreign keys take shared locks you didn&amp;rsquo;t ask for
&lt;/h2&gt;&lt;p&gt;Both MySQL and PostgreSQL acquire shared locks on the referenced row when you insert or update a row with a foreign key. The purpose is to prevent the referenced row from being deleted mid-transaction; you can&amp;rsquo;t have an &lt;code&gt;orders.customer_id&lt;/code&gt; pointing to a &lt;code&gt;customers.id&lt;/code&gt; that&amp;rsquo;s being concurrently deleted.&lt;/p&gt;
&lt;p&gt;The side effect is that a high-write child table concentrates shared locks on hot parent rows:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- customers has id = 42 (a frequently-used customer)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Many concurrent transactions inserting orders for customer 42:
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;amount_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1000&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Takes a shared lock on customers(id=42)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Shared locks don&amp;rsquo;t block each other, so concurrent inserts coexist fine. What breaks is the interaction with any transaction that wants an exclusive lock on the parent row: an update to the customer&amp;rsquo;s name, a soft-delete, a trigger that updates a cached counter. Suddenly, dozens of shared-lock holders are blocking one exclusive-lock request, and if any of them start trying to acquire other locks (say, through a trigger cascade), a cycle can form.&lt;/p&gt;
&lt;p&gt;The symptoms: deadlocks that mention tables far removed from the one the application thought it was touching. &amp;ldquo;Why is my &lt;code&gt;UPDATE customers&lt;/code&gt; deadlocking against an &lt;code&gt;INSERT INTO order_items&lt;/code&gt;?&amp;rdquo; Because the &lt;code&gt;order_items&lt;/code&gt; insert took a shared lock on &lt;code&gt;customers&lt;/code&gt; through the FK chain, and the &lt;code&gt;UPDATE&lt;/code&gt; wanted exclusive on the same row.&lt;/p&gt;
&lt;p&gt;This is one of the hardest patterns to diagnose on sight, because the offending query never references the contended table explicitly. Mitigations (narrowing FK scope, moving hot parent-row updates to side tables, isolation-level trade-offs) are covered in &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/#hot-row-isolation-removing-the-pattern-instead-of-retrying-it" &gt;Part 2: Hot-row isolation&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="index-scans-lock-more-rows-than-queries-return"&gt;Index scans lock more rows than queries return
&lt;/h2&gt;&lt;p&gt;Under InnoDB&amp;rsquo;s default &lt;code&gt;REPEATABLE READ&lt;/code&gt;, an &lt;code&gt;UPDATE&lt;/code&gt; with a &lt;code&gt;WHERE&lt;/code&gt; clause on a non-indexed column acquires a record lock on every row it scans, not just the ones that match. The engine has to examine each row to check the predicate, and it takes a lock to guarantee the check is stable for the duration of the transaction.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Without an index on status, under REPEATABLE READ:
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;priority&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;high&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;pending&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Locks every row in orders during the scan.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;If the table has a million rows and only a thousand match, all million get locked for the duration of the update. Any concurrent transaction touching any of those rows has to wait, which inflates the wait-for graph and makes deadlocks more likely.&lt;/p&gt;
&lt;p&gt;Under &lt;code&gt;READ COMMITTED&lt;/code&gt;, InnoDB narrows this substantially: &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/innodb-transaction-isolation-levels.html" target="_blank" rel="noopener"
 &gt;per the docs&lt;/a&gt;, it releases locks on non-matching rows after the &lt;code&gt;WHERE&lt;/code&gt; evaluation and uses semi-consistent reads, returning the latest committed version of an already-locked row so the engine can check whether it matches the &lt;code&gt;WHERE&lt;/code&gt; before deciding to wait. The net effect is much lower lock footprint and deadlock risk on the same query. PostgreSQL behaves similarly by default: only rows actually updated retain their locks. This is one of the few cases where the same underlying issue (an unindexed predicate) shows up as both a latency problem and a concurrency problem, and where the concurrency angle is specifically a &lt;code&gt;REPEATABLE READ&lt;/code&gt;-on-InnoDB amplifier.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;Secondary index locks on InnoDB&lt;/strong&gt;
 &lt;div&gt;InnoDB takes locks on both the clustered index (primary key) and any secondary indexes touched by the query. A &lt;code&gt;WHERE status = 'pending'&lt;/code&gt; using a &lt;code&gt;status&lt;/code&gt; index locks the relevant index entries and the corresponding PK entries. Transactions that approach the same rows from different indexes (one via &lt;code&gt;status&lt;/code&gt;, another via &lt;code&gt;customer_id&lt;/code&gt;) can deadlock on the PK-side lock even though their index-side locks don&amp;rsquo;t overlap. This is the most common &amp;ldquo;why are these two queries deadlocking, they don&amp;rsquo;t even reference the same columns?&amp;rdquo; failure mode.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="parallelism-induced-deadlocks"&gt;Parallelism-induced deadlocks
&lt;/h2&gt;&lt;p&gt;The lock-ordering patterns above assume two separate transactions from two separate sessions. Parallelism adds a few variants that don&amp;rsquo;t fit that frame; the cycle can form inside a single logical unit of work, or show up only on a replica that never issued the original statements.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Worker pools racing on a shared queue.&lt;/strong&gt; The archetypal production pattern: N application workers pulling jobs from the same table (&lt;code&gt;jobs&lt;/code&gt;, &lt;code&gt;outbox&lt;/code&gt;, &lt;code&gt;email_queue&lt;/code&gt;) and locking rows for processing. If every worker does &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; on &amp;ldquo;the next available batch&amp;rdquo; without a deterministic ordering, two workers can grab overlapping row sets in opposite orders and cycle. This is the same lock-ordering cycle from earlier, distributed across workers that all look identical from a code-review perspective.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Intra-query parallel workers.&lt;/strong&gt; PostgreSQL has a full parallel query executor (parallel sequential scans, bitmap heap scans, index and index-only scans (B-tree), parallel aggregates, parallel joins) that spawns worker processes to cooperate on a single query. MySQL has a much narrower feature: &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/innodb-parameters.html#sysvar_innodb_parallel_read_threads" target="_blank" rel="noopener"
 &gt;&lt;code&gt;innodb_parallel_read_threads&lt;/code&gt;&lt;/a&gt; (added in 8.0.14) enables parallel scanning of the clustered index, used initially by &lt;code&gt;CHECK TABLE&lt;/code&gt; and extended to unconditional &lt;code&gt;SELECT COUNT(*)&lt;/code&gt; in 8.0.17. It is not general parallel query; MySQL does not parallelize arbitrary &lt;code&gt;SELECT&lt;/code&gt;s, joins, or aggregates. In both engines, workers coordinating on a single query don&amp;rsquo;t deadlock among themselves in normal operation; the engine manages the shared lock state. What can happen is a parallel worker holds a lock an unrelated transaction needs, and the parallel query itself takes longer than a serial one would, widening the wait window. Usually not a direct deadlock source, but it changes the timing of existing ones.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Parallel replication on replicas.&lt;/strong&gt; MySQL&amp;rsquo;s multi-threaded replica applies committed transactions in parallel. Transactions that committed serially on the source (no possibility of deadlock there) can deadlock on the replica because the applier threads are racing on rows the source never had concurrent writers on. The replica&amp;rsquo;s deadlock detector resolves them the same way it would a live deadlock, but they show up in the replica&amp;rsquo;s error log with no corresponding entry on the source. Since MySQL 8.0.27, &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/replication-options-replica.html#sysvar_replica_parallel_type" target="_blank" rel="noopener"
 &gt;&lt;code&gt;replica_parallel_type&lt;/code&gt;&lt;/a&gt;&lt;code&gt;=LOGICAL_CLOCK&lt;/code&gt; and &lt;a class="link" href="https://dev.mysql.com/doc/refman/8.0/en/replication-options-replica.html#sysvar_replica_parallel_workers" target="_blank" rel="noopener"
 &gt;&lt;code&gt;replica_parallel_workers&lt;/code&gt;&lt;/a&gt;&lt;code&gt;=4&lt;/code&gt; are the defaults, and &lt;code&gt;replica_parallel_type&lt;/code&gt; was deprecated in 8.0.29; &lt;code&gt;LOGICAL_CLOCK&lt;/code&gt; is effectively the only supported mode going forward. The &lt;code&gt;slave_*&lt;/code&gt; → &lt;code&gt;replica_*&lt;/code&gt; rename happened in 8.0.26; older deployments and blog posts still use the legacy names. PostgreSQL 16+ introduced parallel apply for logical replication (&lt;code&gt;streaming = parallel&lt;/code&gt; is the default on &lt;code&gt;CREATE SUBSCRIPTION&lt;/code&gt;), which exposes the same class of apply-side cycles on a setup that historically didn&amp;rsquo;t have them: a surprise for teams upgrading from 15 and earlier.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Parallel/online DDL interacting with DML.&lt;/strong&gt; Tools like &lt;code&gt;pt-online-schema-change&lt;/code&gt; and &lt;code&gt;gh-ost&lt;/code&gt; run concurrent DML against the table being altered (through triggers or a row-copy process). Under load, the trigger-installed writes and the copy process can both take locks on the same rows the application is updating, and the wait-for graph gains edges that wouldn&amp;rsquo;t exist during a normal workload. This rarely manifests as a hard deadlock (the tools are written defensively) but it does manifest as elevated deadlock rates during the migration window.&lt;/p&gt;
&lt;p&gt;None of these are properties of the queries themselves. They&amp;rsquo;re properties of how work gets distributed across workers, processes, or replicas, which means they&amp;rsquo;re invisible to query-level review and only surface when the deadlock counter is watched over time. The primitives for fixing them (&lt;code&gt;SKIP LOCKED&lt;/code&gt;, &lt;code&gt;NOWAIT&lt;/code&gt;, advisory locks, DDL timeouts) are covered in &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/#nowait-and-skip-locked-as-prevention-primitives" &gt;Part 2: NOWAIT and SKIP LOCKED&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="engine-level-differences-that-shape-the-patterns"&gt;Engine-level differences that shape the patterns
&lt;/h2&gt;&lt;p&gt;The same pattern can deadlock on one engine and not the other. These differences are pattern-shaping; they change which of the above sections apply to your workload. Operational tuning (detector cost, wait timeouts, monitoring) is covered in &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/#monitoring-that-actually-catches-regressions" &gt;Part 2: Monitoring&lt;/a&gt;.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Default isolation.&lt;/strong&gt; PostgreSQL defaults to &lt;code&gt;READ COMMITTED&lt;/code&gt;. MySQL defaults to &lt;code&gt;REPEATABLE READ&lt;/code&gt; (with gap locks). The same application code has measurably different deadlock rates between the two because of this alone, before any other tuning.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Gap locks.&lt;/strong&gt; Only InnoDB has them, and only under &lt;code&gt;REPEATABLE READ&lt;/code&gt; (plus the foreign-key and duplicate-key exceptions that retain gap locking even under &lt;code&gt;READ COMMITTED&lt;/code&gt;). PostgreSQL prevents phantom reads through MVCC at its own &lt;code&gt;REPEATABLE READ&lt;/code&gt; (stricter than the SQL standard requires) without a range-locking mechanism, so the entire gap-lock deadlock category doesn&amp;rsquo;t exist on PG at any isolation level.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Lock granularity.&lt;/strong&gt; PostgreSQL takes row-level (tuple) locks; InnoDB takes record locks on index entries (with next-key extension under &lt;code&gt;REPEATABLE READ&lt;/code&gt;). The practical consequence is that InnoDB locks are more entangled with index choice than PostgreSQL&amp;rsquo;s; changing which index a query uses can change which rows and gaps get locked.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;FK lock style.&lt;/strong&gt; MySQL&amp;rsquo;s FK check holds a shared lock on the referenced row (next-key under &lt;code&gt;REPEATABLE READ&lt;/code&gt;, and the docs list FK checking as one of the places gap locks persist even under &lt;code&gt;READ COMMITTED&lt;/code&gt;). PostgreSQL takes a &lt;code&gt;FOR KEY SHARE&lt;/code&gt; lock (added in 9.3 specifically to reduce FK lock contention vs. the older &lt;code&gt;FOR SHARE&lt;/code&gt;). Hot parent rows are more contended under MySQL as a result.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Row-lock visibility.&lt;/strong&gt; PostgreSQL row-level locks don&amp;rsquo;t show up in &lt;code&gt;pg_locks&lt;/code&gt;. Per the docs, they&amp;rsquo;re stored on the tuple header on disk, not in shared memory. A process waiting for a row lock usually appears in &lt;code&gt;pg_locks&lt;/code&gt; as waiting for the holder&amp;rsquo;s transaction ID, not the row. InnoDB&amp;rsquo;s &lt;code&gt;performance_schema.data_locks&lt;/code&gt; exposes row-level lock state directly. More on this in Part 2.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Neither engine is &amp;ldquo;better.&amp;rdquo; The behaviors are different, and code that assumes one can deadlock mysteriously when moved to the other.&lt;/p&gt;
&lt;h2 id="why-schema-reading-assistants-hit-these-patterns"&gt;Why schema-reading assistants hit these patterns
&lt;/h2&gt;&lt;p&gt;Locking behavior has no syntax in the query text. A &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; advertises the intent; a plain &lt;code&gt;INSERT ... ON DUPLICATE KEY UPDATE&lt;/code&gt; or &lt;code&gt;INSERT ... ON CONFLICT&lt;/code&gt; doesn&amp;rsquo;t. The shared next-key lock on a duplicate-key violation, the FK shared lock on the parent row, the gap-lock extension under MySQL&amp;rsquo;s default isolation are all implementation details of the storage engine. Schema-reading assistants read the catalog, which describes tables, columns, and constraints, and the codebase, which describes queries. Neither surfaces lock ordering, gap-lock semantics, or the difference between &lt;code&gt;READ COMMITTED&lt;/code&gt; and &lt;code&gt;REPEATABLE READ&lt;/code&gt; unless the prompt specifically includes them.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s why AI-generated UPSERT and batch-insert code deadlocks in production the way it does. The model reads &lt;code&gt;INSERT ... ON DUPLICATE KEY UPDATE&lt;/code&gt; as an atomic upsert, not as &amp;ldquo;takes a shared next-key lock, possibly including a gap, before raising the duplicate-key error that the application will retry.&amp;rdquo; It generates batch INSERTs that process rows in whatever order the application supplies, not sorted by key: fine for a single writer, a lock-ordering cycle under any realistic concurrency. The patterns above are the ones the catalog and query text can&amp;rsquo;t warn about, and they&amp;rsquo;re the ones that arrive in production as &amp;ldquo;intermittent deadlocks under load&amp;rdquo; after passing every test that didn&amp;rsquo;t include a second concurrent worker. The fix lives one level up from the query (sorted batches, explicit lock ordering, retry loops) which is the subject of Part 2.&lt;/p&gt;
&lt;h2 id="whats-in-part-2"&gt;What&amp;rsquo;s in Part 2
&lt;/h2&gt;&lt;p&gt;The patterns are the first half. Turning them into working systems takes a different set of skills: reading the deadlock log to identify which pattern is firing, building retry logic that doesn&amp;rsquo;t mask the real bug, isolating hot rows before they become incident reports, and choosing the right tool for each (&lt;code&gt;NOWAIT&lt;/code&gt;, &lt;code&gt;SKIP LOCKED&lt;/code&gt;, advisory locks, counter tables, or the isolation-level change that eliminates the category entirely). PostgreSQL&amp;rsquo;s &lt;code&gt;SERIALIZABLE&lt;/code&gt;/SSI produces serialization failures that look like deadlocks but aren&amp;rsquo;t; the difference matters for retry architecture. &lt;code&gt;AUTO_INCREMENT&lt;/code&gt; and sequence-related locking have their own failure modes. DDL migrations on both engines introduce lock queues that manifest as deadlock-like incidents.&lt;/p&gt;
&lt;p&gt;All of that is in &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/" &gt;Database Deadlocks, Part 2: Diagnosis, Retries, and Prevention&lt;/a&gt;.&lt;/p&gt;
&lt;h2 id="mental-model-for-the-patterns"&gt;Mental model for the patterns
&lt;/h2&gt;&lt;p&gt;Deadlocks are what consistent concurrency control does when two transactions make the engine choose between them. The database isn&amp;rsquo;t misbehaving; it&amp;rsquo;s refusing to let both of two contradictory orderings win. The error in the application log is a notification, not a fault.&lt;/p&gt;
&lt;p&gt;That makes the diagnostic question concrete. Which pattern is firing? Every deadlock in production fits one of the shapes above: lock-ordering cycle, gap lock on a range, duplicate-key shared lock, FK shared lock on a hot parent, unindexed predicate lock amplification, worker-pool race, or replication-apply cycle. Identifying the pattern from the deadlock log narrows the fix enormously. &amp;ldquo;Two transactions deadlocked, retry the transaction&amp;rdquo; is true but useless. &amp;ldquo;Two workers took locks on the same jobs queue in different orders, switch to &lt;code&gt;SKIP LOCKED&lt;/code&gt;&amp;rdquo; is a fix. The work is in the identification.&lt;/p&gt;</description></item><item><title>NULL in SQL: Three-Valued Logic and the Silent Bug Factory</title><link>https://explainanalyze.com/p/null-in-sql-three-valued-logic-and-the-silent-bug-factory/</link><pubDate>Sun, 26 Jan 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/null-in-sql-three-valued-logic-and-the-silent-bug-factory/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post NULL in SQL: Three-Valued Logic and the Silent Bug Factory" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;NULL is the absence of a value, and SQL evaluates expressions involving it under three-valued logic (TRUE / FALSE / UNKNOWN). Most operators return UNKNOWN when one of their operands is NULL, so rows with NULLs silently drop out of &lt;code&gt;!=&lt;/code&gt;, &lt;code&gt;IN&lt;/code&gt;, and &lt;code&gt;NOT IN&lt;/code&gt; filters and behave inconsistently across &lt;code&gt;JOIN&lt;/code&gt;, &lt;code&gt;GROUP BY&lt;/code&gt;, &lt;code&gt;DISTINCT&lt;/code&gt;, and aggregate functions. The rules are consistent if you know them, and a source of silently wrong results when you don&amp;rsquo;t.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;There&amp;rsquo;s a category of SQL bug that shows up in almost every mature codebase. Someone writes a filter like &lt;code&gt;WHERE status != 'closed'&lt;/code&gt;, expecting it to return every row that isn&amp;rsquo;t closed. Instead it returns fewer rows than the raw table contains. The rows where &lt;code&gt;status&lt;/code&gt; is NULL silently dropped out. No error. No warning. The query is doing exactly what the SQL standard says it should, and the result is still wrong for what the author meant.&lt;/p&gt;
&lt;p&gt;NULL handling is the single most common source of silently wrong query results in relational databases. The behavior is consistent if you know the rules, but the rules don&amp;rsquo;t match the intuition most programming languages build. In Java or Python, &lt;code&gt;null != &amp;quot;closed&amp;quot;&lt;/code&gt; is &lt;code&gt;true&lt;/code&gt;. In SQL, it&amp;rsquo;s UNKNOWN, and UNKNOWN rows get filtered out. That one difference produces most of the bugs.&lt;/p&gt;
&lt;h2 id="null-is-not-a-value"&gt;NULL is not a value
&lt;/h2&gt;&lt;p&gt;Every introduction to SQL NULL starts here because it has to. NULL is the absence of a value, a marker that says &amp;ldquo;this column has no data.&amp;rdquo; It&amp;rsquo;s not zero, not empty string, not false. It doesn&amp;rsquo;t equal itself. It doesn&amp;rsquo;t not-equal itself either. Any comparison involving NULL returns a third logical state: UNKNOWN.&lt;/p&gt;
&lt;p&gt;This is called three-valued logic (3VL), and SQL uses it consistently throughout the language. The three values are TRUE, FALSE, and UNKNOWN. Most operators propagate UNKNOWN: any arithmetic, string, or comparison operation with a NULL operand returns NULL (or UNKNOWN, in a boolean context).&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- NULL (not TRUE)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- NULL
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;5&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- NULL
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- NULL
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;hello&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;||&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- NULL in PostgreSQL (ANSI standard behavior)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;CONCAT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;hello&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- NULL in MySQL, &amp;#39;hello&amp;#39; in PostgreSQL
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The &lt;code&gt;CONCAT&lt;/code&gt; difference is a good example of how engines diverge even within well-defined territory. MySQL&amp;rsquo;s &lt;code&gt;CONCAT&lt;/code&gt; propagates NULL: any NULL argument makes the whole result NULL. PostgreSQL&amp;rsquo;s &lt;code&gt;CONCAT&lt;/code&gt; function does the opposite, silently skipping NULL arguments and returning the concatenation of the non-NULL parts. (PostgreSQL&amp;rsquo;s &lt;code&gt;||&lt;/code&gt; operator still propagates NULL, matching ANSI.) Two queries that look identical can return different results on different engines, and the difference only shows up when a NULL appears.&lt;/p&gt;
&lt;p&gt;For NULL-skipping concatenation that behaves the same on both engines, use &lt;code&gt;CONCAT_WS&lt;/code&gt; (concat with separator). Both MySQL and PostgreSQL skip NULL arguments with it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;CONCAT_WS&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39; &amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;first_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;middle_name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;last_name&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- &amp;#34;Alice Smith&amp;#34; even if middle_name IS NULL, on both engines.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;One MySQL-specific gotcha: if the separator itself is NULL, the whole result is NULL. The separator is the one argument &lt;code&gt;CONCAT_WS&lt;/code&gt; still propagates NULL from. As long as the separator is a literal string, the function is a reliable NULL-safe concat across engines.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;IS NULL, not = NULL&lt;/strong&gt;
 &lt;div&gt;The only way to test for NULL is with &lt;code&gt;IS NULL&lt;/code&gt; or &lt;code&gt;IS NOT NULL&lt;/code&gt;. &lt;code&gt;WHERE col = NULL&lt;/code&gt; always returns zero rows, because &lt;code&gt;col = NULL&lt;/code&gt; evaluates to NULL, which is not TRUE, so the row is filtered out. This is one of those mistakes every SQL engineer makes exactly once.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="where-clauses-filter-out-unknown"&gt;WHERE clauses filter out UNKNOWN
&lt;/h2&gt;&lt;p&gt;The rule that drives most NULL bugs: &lt;code&gt;WHERE&lt;/code&gt; only keeps rows where the condition evaluates to TRUE. UNKNOWN rows are filtered out, same as FALSE rows.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- &amp;#34;Users not on the sales team&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;team_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&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&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;If &lt;code&gt;team_id&lt;/code&gt; is NULL for unassigned users (a completely normal state) those rows are silently dropped. The expression &lt;code&gt;NULL != 3&lt;/code&gt; evaluates to UNKNOWN, and UNKNOWN is not TRUE, so the row doesn&amp;rsquo;t survive the filter.&lt;/p&gt;
&lt;p&gt;The mental model most developers carry from application code (&amp;ldquo;anything that isn&amp;rsquo;t team 3 is included&amp;rdquo;) is wrong in SQL. To get that behavior, you have to spell it out:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;team_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;!=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;3&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;OR&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;team_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;This is one of the most common sources of &amp;ldquo;the numbers don&amp;rsquo;t match&amp;rdquo; bugs. A report that&amp;rsquo;s supposed to count &amp;ldquo;everyone outside the sales team&amp;rdquo; quietly excludes every unassigned user, and the total looks plausible because unassigned users aren&amp;rsquo;t visible in the team-level breakdown either. The discrepancy only surfaces when someone reconciles against a direct row count.&lt;/p&gt;
&lt;h2 id="not-in-is-a-trap"&gt;NOT IN is a trap
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;NOT IN&lt;/code&gt; with a nullable subquery is the classic silent-failure NULL bug. The trap is specifically that the subquery has to return a column that can contain NULL: rules out primary keys but extremely common for foreign keys, self-references, and any column that&amp;rsquo;s optional by design.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- &amp;#34;Find users who aren&amp;#39;t anybody&amp;#39;s manager.&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;manager_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The subquery returns every &lt;code&gt;manager_id&lt;/code&gt; in the table, including NULL for users who don&amp;rsquo;t have a manager (the CEO, top-level roles, anyone unassigned). The moment the subquery contains a single NULL, the outer query returns zero rows.&lt;/p&gt;
&lt;p&gt;The reason is how &lt;code&gt;NOT IN&lt;/code&gt; expands. &lt;code&gt;x NOT IN (a, b, c)&lt;/code&gt; is equivalent to &lt;code&gt;x != a AND x != b AND x != c&lt;/code&gt;. If any of &lt;code&gt;a&lt;/code&gt;, &lt;code&gt;b&lt;/code&gt;, &lt;code&gt;c&lt;/code&gt; is NULL, that comparison returns UNKNOWN, and AND with UNKNOWN can only ever be FALSE or UNKNOWN. The row never passes the filter.&lt;/p&gt;
&lt;p&gt;Safer alternatives:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Use NOT EXISTS - handles NULLs correctly
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;EXISTS&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;manager_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Or filter NULLs out of the subquery explicitly
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IN&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;manager_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;manager_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;NOT EXISTS&lt;/code&gt; is the better habit. It&amp;rsquo;s correct regardless of NULL presence, and the query planner handles it at least as well as &lt;code&gt;NOT IN&lt;/code&gt; on any modern engine. Treating &lt;code&gt;NOT IN&lt;/code&gt; as &amp;ldquo;suspicious until proven NULL-free&amp;rdquo; saves a category of bug that&amp;rsquo;s almost impossible to catch in review.&lt;/p&gt;
&lt;h2 id="count-and-null-skipped-not-zero"&gt;COUNT and NULL: skipped, not zero
&lt;/h2&gt;&lt;p&gt;The single most important thing to know about aggregates and NULL: NULL is not treated as zero. It&amp;rsquo;s skipped entirely. Nothing about NULL gets coerced or counted; it&amp;rsquo;s as if the row weren&amp;rsquo;t there for the purposes of the aggregate.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;COUNT&lt;/code&gt; makes this visible because it has three forms that behave differently:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;COUNT(*)&lt;/code&gt; counts &lt;strong&gt;rows&lt;/strong&gt;, regardless of their contents. NULLs in the row don&amp;rsquo;t matter.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;COUNT(col)&lt;/code&gt; counts &lt;strong&gt;non-NULL values&lt;/strong&gt; of &lt;code&gt;col&lt;/code&gt;. A row where &lt;code&gt;col IS NULL&lt;/code&gt; is skipped.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;COUNT(DISTINCT col)&lt;/code&gt; counts &lt;strong&gt;distinct non-NULL values&lt;/strong&gt;. NULL is not treated as a distinct value; it&amp;rsquo;s excluded.&lt;/li&gt;
&lt;/ul&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&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="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total_rows&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;rows_with_email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&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="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;distinct_emails&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;On a table of 1,000 users where 200 have NULL emails:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;COUNT(*)&lt;/code&gt; returns 1000 (all rows)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;COUNT(email)&lt;/code&gt; returns 800 (NULLs skipped)&lt;/li&gt;
&lt;li&gt;&lt;code&gt;COUNT(DISTINCT email)&lt;/code&gt; returns ≤ 800 (distinct non-NULL emails only)&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;This shows up in reports all the time. &amp;ldquo;How many users signed up this month?&amp;rdquo; gets answered with &lt;code&gt;COUNT(signup_source)&lt;/code&gt; and comes up short because the column was added later and older rows have NULL. The row is there. &lt;code&gt;COUNT(*)&lt;/code&gt; would see it. &lt;code&gt;COUNT(signup_source)&lt;/code&gt; doesn&amp;rsquo;t.&lt;/p&gt;
&lt;p&gt;The rule: use &lt;code&gt;COUNT(*)&lt;/code&gt; when you want rows, &lt;code&gt;COUNT(col)&lt;/code&gt; when you specifically want &amp;ldquo;rows with that column populated.&amp;rdquo;&lt;/p&gt;
&lt;h2 id="sum-avg-min-max-also-skip-null"&gt;SUM, AVG, MIN, MAX: also skip NULL
&lt;/h2&gt;&lt;p&gt;The same rule holds for every aggregate. NULL is not contributed to the sum, not counted in the denominator for the average, not considered for min or max.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rating&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rating&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;reviews&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;If half the rows have NULL rating:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;code&gt;SUM(rating)&lt;/code&gt; is the sum of the non-NULL half. NULLs don&amp;rsquo;t contribute 0; they contribute nothing.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;AVG(rating)&lt;/code&gt; is the sum of the non-NULL half divided by the &lt;strong&gt;count of non-NULL rows&lt;/strong&gt;, not the total row count.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The AVG behavior is the most common source of surprise. If 10,000 rows have &lt;code&gt;rating = 5&lt;/code&gt; and 10,000 have &lt;code&gt;rating = NULL&lt;/code&gt;, &lt;code&gt;AVG(rating)&lt;/code&gt; is 5.0, not 2.5. The NULL rows don&amp;rsquo;t pull the average down toward zero. They&amp;rsquo;re not in the denominator at all.&lt;/p&gt;
&lt;p&gt;If you want NULL-as-zero behavior, you have to opt in:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AVG&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;COALESCE&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;rating&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="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;reviews&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Now NULLs become 0 and land in both the sum and the denominator.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Returns 2.5 in the example above.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;div class="note-box"&gt;
 &lt;strong&gt;SUM of all NULLs is NULL, not zero&lt;/strong&gt;
 &lt;div&gt;&lt;code&gt;SUM(col)&lt;/code&gt; over a set where every value is NULL returns NULL, not 0. A &lt;code&gt;SUM&lt;/code&gt; that feeds into arithmetic downstream (&lt;code&gt;total + tax&lt;/code&gt;, for example) can propagate NULL through the rest of the expression, often somewhere the query author wasn&amp;rsquo;t expecting. &lt;code&gt;COALESCE(SUM(col), 0)&lt;/code&gt; is the idiomatic fix; make the fallback explicit at the aggregate.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The framing that keeps this straight: NULL is not a value, so aggregates have nothing to aggregate. Absent, not zero. If you want absent to mean zero, that&amp;rsquo;s a &lt;code&gt;COALESCE&lt;/code&gt; decision the query author makes; the engine won&amp;rsquo;t make it for you.&lt;/p&gt;
&lt;h2 id="group-by-and-distinct-treat-nulls-as-equal"&gt;GROUP BY and DISTINCT treat NULLs as equal
&lt;/h2&gt;&lt;p&gt;Here&amp;rsquo;s where the rules get inconsistent in a way that genuinely surprises people: &lt;code&gt;GROUP BY&lt;/code&gt; and &lt;code&gt;DISTINCT&lt;/code&gt; treat all NULLs as the same group, even though &lt;code&gt;NULL = NULL&lt;/code&gt; returns UNKNOWN.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- All rows where team_id is NULL land in one group, as if they were equal.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;team_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&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="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;team_id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- team_id | count
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- NULL | 200
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- 1 | 500
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- 2 | 300
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- DISTINCT collapses all NULLs into one row.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;team_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- NULL
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- 1
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- 2
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;This is a deliberate exception carved out by the SQL standard. &lt;code&gt;GROUP BY&lt;/code&gt; and &lt;code&gt;DISTINCT&lt;/code&gt; use a &amp;ldquo;NULL-safe&amp;rdquo; equality for grouping purposes, because the alternative (one group per NULL row) would be useless. But it means the behavior is internally inconsistent: &lt;code&gt;WHERE a = b&lt;/code&gt; says NULLs aren&amp;rsquo;t equal, &lt;code&gt;GROUP BY a&lt;/code&gt; says they are.&lt;/p&gt;
&lt;p&gt;The practical implication: &lt;code&gt;COUNT(DISTINCT col)&lt;/code&gt; excludes NULL entirely (consistent with &lt;code&gt;COUNT(col)&lt;/code&gt;), while &lt;code&gt;GROUP BY col&lt;/code&gt; produces a single row for all NULLs. Two different &amp;ldquo;null-handling&amp;rdquo; behaviors under the same umbrella of &amp;ldquo;treats NULLs as equal for grouping.&amp;rdquo; Queries that rely on either for correctness should be written with the awareness that the two operations don&amp;rsquo;t agree.&lt;/p&gt;
&lt;h2 id="null-safe-comparison-operators"&gt;NULL-safe comparison operators
&lt;/h2&gt;&lt;p&gt;Both MySQL and PostgreSQL offer operators that treat NULL as equal to NULL, mirroring the &lt;code&gt;GROUP BY&lt;/code&gt; behavior for regular comparisons.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- MySQL
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;lt;=&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Matches rows where email IS NULL. &amp;lt;=&amp;gt; is the null-safe equal operator.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- PostgreSQL (ANSI SQL)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Same idea. Treats NULLs as equal to each other.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;These are useful when joining or filtering on columns that may contain NULL on both sides and you want NULLs to match:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Standard equality misses NULL-to-NULL matches
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- IS NOT DISTINCT FROM treats NULLs as matching
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;col&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Neither is used often in practice. The habit most teams settle on is &amp;ldquo;don&amp;rsquo;t let NULL be meaningful in join columns&amp;rdquo;: either constrain the columns &lt;code&gt;NOT NULL&lt;/code&gt; or filter NULLs out before joining. The operators are there for the cases where those aren&amp;rsquo;t options.&lt;/p&gt;
&lt;h2 id="order-by-null-placement-varies-by-engine"&gt;ORDER BY: NULL placement varies by engine
&lt;/h2&gt;&lt;p&gt;When sorting, NULL has to go somewhere. The SQL standard leaves the default placement implementation-defined, and engines disagree.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;PostgreSQL.&lt;/strong&gt; NULLs sort last for &lt;code&gt;ASC&lt;/code&gt; and first for &lt;code&gt;DESC&lt;/code&gt; by default.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;MySQL.&lt;/strong&gt; NULLs sort first for &lt;code&gt;ASC&lt;/code&gt; and last for &lt;code&gt;DESC&lt;/code&gt; by default.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Oracle and SQL Server.&lt;/strong&gt; Match PostgreSQL&amp;rsquo;s behavior (NULLs last for &lt;code&gt;ASC&lt;/code&gt;).&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The fix is to be explicit:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;event_time&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ASC&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NULLS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LAST&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;event_time&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DESC&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;NULLS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;LAST&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;code&gt;NULLS FIRST&lt;/code&gt; / &lt;code&gt;NULLS LAST&lt;/code&gt; is ANSI standard and supported by PostgreSQL, Oracle, and SQL Server. MySQL doesn&amp;rsquo;t support the &lt;code&gt;NULLS FIRST/LAST&lt;/code&gt; syntax directly; you fake it with a computed column:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- MySQL idiom for &amp;#34;NULLS LAST&amp;#34; on an ASC sort
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;event_time&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;event_time&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ASC&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- event_time IS NULL returns 0 for non-nulls, 1 for nulls; 0 sorts first.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Teams that run the same reports against different engines (especially during a migration or in a polyglot analytics stack) hit this one hard. A top-10 leaderboard quietly reorders when the ORDER BY engine changes underneath it.&lt;/p&gt;
&lt;h2 id="joins-dont-match-on-null"&gt;JOINs don&amp;rsquo;t match on NULL
&lt;/h2&gt;&lt;p&gt;A standard equi-join &lt;code&gt;a.col = b.col&lt;/code&gt; doesn&amp;rsquo;t match rows where either side is NULL. This is consistent with the three-valued logic rule: &lt;code&gt;NULL = NULL&lt;/code&gt; is UNKNOWN, so the join predicate fails.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Users can have no manager (manager_id IS NULL).
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- This join drops any user with no manager.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;manager_name&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;managers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;u&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;manager_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;m&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;If the intent is &amp;ldquo;every user, with manager info if present,&amp;rdquo; use a LEFT JOIN. If the intent is &amp;ldquo;users where manager_id matches some manager row,&amp;rdquo; the INNER JOIN is correct but it&amp;rsquo;s worth naming the exclusion: users with NULL &lt;code&gt;manager_id&lt;/code&gt; are gone, on purpose.&lt;/p&gt;
&lt;p&gt;For joins that should treat NULLs as matching (both sides have NULL, and that means &amp;ldquo;same&amp;rdquo;), use the null-safe operator:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;a&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;external_ref&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;b&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;external_ref&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;This is rare but legitimate (e.g., matching optional identifiers where &amp;ldquo;both unspecified&amp;rdquo; should be treated as a match). Most of the time, the correct answer is to make the column &lt;code&gt;NOT NULL&lt;/code&gt; and use a sentinel if needed (and then deal with the sentinel&amp;rsquo;s own problems, covered below).&lt;/p&gt;
&lt;h2 id="foreign-keys-are-nullable-by-default"&gt;Foreign keys are nullable by default
&lt;/h2&gt;&lt;p&gt;A foreign key column is nullable unless declared &lt;code&gt;NOT NULL&lt;/code&gt;. A nullable FK means the reference is optional: users may or may not have a manager, orders may or may not be linked to a promotion. This is often the correct intent, but it&amp;rsquo;s frequently unintentional.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- manager_id is nullable by default. This is intentional if users can be unmanaged.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;TEXT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;manager_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;managers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Review migration files with this in mind. A column that should always be populated but was added as nullable will accept NULLs forever. Retrofitting &lt;code&gt;NOT NULL&lt;/code&gt; later requires backfilling or cleaning up existing NULL rows: easy when the table is small, painful at scale. (&lt;a class="link" href="https://explainanalyze.com/p/foreign-keys-are-not-optional/#what-foreign-keys-actually-give-you" &gt;Foreign Keys Are Not Optional&lt;/a&gt; covers the broader picture of FK enforcement and why application-level validation is an incomplete substitute.)&lt;/p&gt;
&lt;h2 id="what-null-actually-means-is-context-dependent"&gt;What NULL actually means is context-dependent
&lt;/h2&gt;&lt;p&gt;The SQL rules for NULL are unambiguous. What NULL means in a given column is not. NULL can mean:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Unknown.&lt;/strong&gt; The data exists but we don&amp;rsquo;t have it. A user&amp;rsquo;s birthdate where the user declined to share.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Not applicable.&lt;/strong&gt; The field doesn&amp;rsquo;t make sense for this row. &lt;code&gt;spouse_name&lt;/code&gt; on a row for a single person.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Ongoing or not yet set.&lt;/strong&gt; The state isn&amp;rsquo;t finalized. &lt;code&gt;end_date&lt;/code&gt; on an active subscription.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Data entry error.&lt;/strong&gt; The column should have been populated but wasn&amp;rsquo;t.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Legacy.&lt;/strong&gt; The column was added after the row was created and never backfilled.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The same column may mean different things in different rows, and the schema doesn&amp;rsquo;t tell you which is which. This is where &lt;a class="link" href="https://explainanalyze.com/p/comment-your-schema/#what-to-comment" &gt;schema comments&lt;/a&gt; earn their keep, documenting the semantics of NULL in each column in the DDL itself rather than in a wiki page nobody finds.&lt;/p&gt;
&lt;h2 id="sentinel-values-the-alternative-and-its-own-problems"&gt;Sentinel values: the alternative, and its own problems
&lt;/h2&gt;&lt;p&gt;A common workaround: use a sentinel value instead of NULL. &lt;code&gt;end_date = '9999-12-31'&lt;/code&gt; for &amp;ldquo;ongoing.&amp;rdquo; &lt;code&gt;status = -1&lt;/code&gt; for &amp;ldquo;unknown.&amp;rdquo; &lt;code&gt;deleted_at = '1970-01-01'&lt;/code&gt; for &amp;ldquo;not deleted.&amp;rdquo;&lt;/p&gt;
&lt;p&gt;Sentinels avoid the three-valued-logic rules at the cost of introducing their own bugs. A few to watch for:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Aggregates include sentinels.&lt;/strong&gt; &lt;code&gt;AVG(rating)&lt;/code&gt; over a column where &amp;ldquo;unknown&amp;rdquo; is stored as &lt;code&gt;-1&lt;/code&gt; skews the average toward negative. Sentinels break the &amp;ldquo;aggregates skip missing values&amp;rdquo; assumption that NULL provides for free.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Range queries break in unexpected directions.&lt;/strong&gt; &lt;code&gt;WHERE end_date &amp;gt; NOW()&lt;/code&gt; returns all the sentinel rows along with real future dates. Every filter has to explicitly exclude the sentinel.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Indexes skew.&lt;/strong&gt; A column where 80% of the values are the sentinel has a low-selectivity index. The planner may skip the index entirely on queries that filter out the sentinel, because it doesn&amp;rsquo;t know that&amp;rsquo;s the intent.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Downstream consumers have to know.&lt;/strong&gt; Every system that reads the data has to treat &lt;code&gt;9999-12-31&lt;/code&gt; specially. Miss one consumer and wrong data shows up in a report.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The trade-off is real. NULL forces every query author to think about three-valued logic. Sentinels let queries use normal equality but require every author to know the sentinel. Neither is free; they move the cost around.&lt;/p&gt;
&lt;p&gt;The pragmatic middle ground: use NULL for genuinely absent data (ongoing subscriptions, optional fields), use sentinels sparingly and document them, and declare &lt;code&gt;NOT NULL&lt;/code&gt; everywhere you can enforce presence. A column that&amp;rsquo;s &lt;code&gt;NOT NULL&lt;/code&gt; is the one case where the rules don&amp;rsquo;t matter, because NULL can&amp;rsquo;t get in.&lt;/p&gt;
&lt;h2 id="diagnosing-a-null-bug"&gt;Diagnosing a NULL bug
&lt;/h2&gt;&lt;p&gt;When a query returns fewer (or more, or none) of the rows it should, the fastest way to narrow it down to a NULL issue:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Are there NULLs in the columns referenced by the filter?
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&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="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;team_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;with_team&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&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="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;-&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;team_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;no_team&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;If &lt;code&gt;no_team&lt;/code&gt; is non-zero and the filter is &lt;code&gt;team_id != X&lt;/code&gt; or &lt;code&gt;team_id IN (...)&lt;/code&gt;, the NULL rows are the likely culprit. Rewriting with explicit NULL handling (&lt;code&gt;team_id != X OR team_id IS NULL&lt;/code&gt;, or &lt;code&gt;NOT EXISTS&lt;/code&gt;, or &lt;code&gt;COALESCE(team_id, -1) != X&lt;/code&gt;) will reveal whether NULLs were being silently excluded.&lt;/p&gt;
&lt;p&gt;For &lt;code&gt;NOT IN&lt;/code&gt;, inspect the subquery:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Does the NOT IN subquery contain NULL?
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&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="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;manager_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;If the answer is non-zero, &lt;code&gt;NOT IN&lt;/code&gt; is returning an empty set regardless of the outer query&amp;rsquo;s data.&lt;/p&gt;
&lt;h2 id="the-mental-model"&gt;The mental model
&lt;/h2&gt;&lt;p&gt;NULL handling is consistent once you internalize the rule set, and the rule set is smaller than it looks:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Any comparison involving NULL returns UNKNOWN. &lt;code&gt;WHERE&lt;/code&gt; filters out UNKNOWN rows.&lt;/li&gt;
&lt;li&gt;Aggregates skip NULLs. &lt;code&gt;COUNT(*)&lt;/code&gt; doesn&amp;rsquo;t. &lt;code&gt;COUNT(col)&lt;/code&gt; does.&lt;/li&gt;
&lt;li&gt;&lt;code&gt;GROUP BY&lt;/code&gt;, &lt;code&gt;DISTINCT&lt;/code&gt;, and &lt;code&gt;ORDER BY&lt;/code&gt; treat all NULLs as equivalent (with engine-specific sort placement).&lt;/li&gt;
&lt;li&gt;&lt;code&gt;NOT IN&lt;/code&gt; with a nullable subquery returns empty. Use &lt;code&gt;NOT EXISTS&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;Join predicates don&amp;rsquo;t match NULLs unless you use &lt;code&gt;IS NOT DISTINCT FROM&lt;/code&gt;.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Past that, most NULL bugs are prevented by one habit: declare &lt;code&gt;NOT NULL&lt;/code&gt; wherever the column should actually be populated. Every &lt;code&gt;NOT NULL&lt;/code&gt; column is a column where none of these rules matter, because there&amp;rsquo;s nothing for them to misbehave on. The fewer nullable columns the schema has, the less of this there is to think about.&lt;/p&gt;
&lt;p&gt;The columns where NULL genuinely carries meaning (optional references, ongoing states, data that may not exist) are the ones worth documenting. A schema comment that says &amp;ldquo;NULL means the subscription is still active&amp;rdquo; pulls the NULL semantics into the DDL itself, where it&amp;rsquo;s visible to every engineer, every tool, and every query author who wasn&amp;rsquo;t around when the decision was made.&lt;/p&gt;</description></item><item><title>Joins That Lie: The Cardinality Problem</title><link>https://explainanalyze.com/p/joins-that-lie-the-cardinality-problem/</link><pubDate>Thu, 09 Jan 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/joins-that-lie-the-cardinality-problem/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Joins That Lie: The Cardinality Problem" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;Most silently wrong SQL comes from the same root cause: a join that multiplies rows in a way the author didn&amp;rsquo;t expect. Aggregations built on those rows (&lt;code&gt;SUM&lt;/code&gt;, &lt;code&gt;COUNT&lt;/code&gt;, &lt;code&gt;AVG&lt;/code&gt;) inflate without producing any error. The fix is understanding the cardinality of every join before writing the aggregation, not more careful SQL.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;There&amp;rsquo;s a category of SQL bug that never throws an error, never fails code review, and never shows up in tests. The query runs. The results look reasonable. Someone ships a dashboard, and a month later finance asks why revenue is 40% higher than what the billing system reports. That 40% isn&amp;rsquo;t a bug in the data; it&amp;rsquo;s a join that multiplied rows, and a &lt;code&gt;SUM&lt;/code&gt; that dutifully added them all up.&lt;/p&gt;
&lt;p&gt;The tricky part is that structurally the query is fine. The joins are valid. The filters are valid. The aggregation is valid. Every individual piece is correct. The cardinality of the relationships (how many child rows exist per parent, and how that changes when multiple child tables are joined at once) is doing damage the query never surfaces.&lt;/p&gt;
&lt;h2 id="cardinality-briefly"&gt;Cardinality, briefly
&lt;/h2&gt;&lt;p&gt;Cardinality describes the number of rows on each side of a relationship:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;One-to-one (1:1).&lt;/strong&gt; Each row in table A matches at most one row in table B. Less common than 1:N, but legitimately used for optional extensions (splitting off rarely-accessed or sensitive columns into a side table), inheritance patterns (a base table with specialized sub-tables), or separating hot and cold data for caching and storage reasons. A 1:1 join preserves row count.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;One-to-many (1:N).&lt;/strong&gt; Each row in A matches zero or more rows in B. The common case: one order has many order items, one user has many sessions, one post has many comments. Joining A to B duplicates the parent row once per matching child. If a parent has zero children, an inner join drops it entirely; a left join keeps it with NULLs on the child side. This difference matters and it&amp;rsquo;s the source of another whole class of silent bugs.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Many-to-many (N:M).&lt;/strong&gt; Rows in A match many rows in B and vice versa. Always implemented through a bridge table (junction table) that sits between them. A bridge is two 1:N relationships back-to-back: the bridge table holds a foreign key to A and a foreign key to B, with each row pairing one A with one B. A has many bridge rows, and B has many bridge rows. Joining through it multiplies by the cardinality on both sides.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The shape of the relationship determines what a join does to row counts. This is where aggregations start to lie.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;Schema cardinality vs. data cardinality&lt;/strong&gt;
 &lt;div&gt;There&amp;rsquo;s a distinction worth naming: what the schema allows vs. what the data actually contains. A foreign key from &lt;code&gt;user_profiles.user_id&lt;/code&gt; to &lt;code&gt;users.id&lt;/code&gt; with a unique constraint is 1:1 at the schema level. A column typed as 1:N by constraint can be 1:1 in practice; if every order in your system happens to have exactly one line item, the relationship is legally 1:N but effectively 1:1. This matters for query planning (the optimizer uses constraints, not observed data), index choice, and reasoning about whether a join can actually multiply rows. A query that&amp;rsquo;s safe against the current data can break as soon as the data starts exercising the cardinality the schema permits.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="the-row-multiplication-problem"&gt;The row multiplication problem
&lt;/h2&gt;&lt;p&gt;The examples in this article use a deliberately simple &lt;code&gt;customers&lt;/code&gt; / &lt;code&gt;orders&lt;/code&gt; / &lt;code&gt;order_items&lt;/code&gt; schema so the mechanics are easy to follow. In real systems the shape changes constantly: invoices and payments, subscriptions and usage events, tickets and messages, events and dimensions in a warehouse. The permutations are endless, but the underlying failure is the same: a join that multiplies rows in a way the author didn&amp;rsquo;t expect, feeding an aggregation that now lies. Once the pattern is visible in one schema, it&amp;rsquo;s visible everywhere.&lt;/p&gt;
&lt;p&gt;Consider a schema everyone has seen some version of:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total_cents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_items&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;price_cents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;quantity&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;A question: what&amp;rsquo;s the total revenue per customer? The obvious query:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;This is correct. One row per order, &lt;code&gt;total_cents&lt;/code&gt; summed per customer. Now someone asks: &amp;ldquo;can we also see how many items they bought?&amp;rdquo; The change looks trivial; add a join and a count:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;items_purchased&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_items&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The &lt;code&gt;items_purchased&lt;/code&gt; count is correct. The &lt;code&gt;revenue&lt;/code&gt; is wrong.&lt;/p&gt;
&lt;p&gt;Here&amp;rsquo;s what happened. &lt;code&gt;orders&lt;/code&gt; to &lt;code&gt;order_items&lt;/code&gt; is 1:N. Joining them multiplies each order row by the number of items it contains. An order with 5 items now appears 5 times in the result set, once per item. &lt;code&gt;total_cents&lt;/code&gt;, which lives on the &lt;code&gt;orders&lt;/code&gt; row, is duplicated in each of those 5 copies.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;SUM(o.total_cents)&lt;/code&gt; now sums the same order total once per item. A $100 order with 5 items contributes $500. Revenue is inflated by the average number of items per order.&lt;/p&gt;
&lt;p&gt;The query runs. The numbers look like revenue. Nothing is flagged. The dashboard ships.&lt;/p&gt;
&lt;div class="note-box"&gt;
 &lt;strong&gt;Why it&amp;#39;s easy to miss&lt;/strong&gt;
 &lt;div&gt;The inflation is proportional to the cardinality of the join, so it affects every row by roughly the same factor. Totals grow uniformly, relative rankings stay intact, and top-10 lists still look &amp;ldquo;right.&amp;rdquo; There&amp;rsquo;s nothing that stands out as obviously wrong, except the grand total doesn&amp;rsquo;t match the source system.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="the-bridge-table-trap"&gt;The bridge table trap
&lt;/h2&gt;&lt;p&gt;Many-to-many relationships make this problem worse because the multiplication happens in both directions. Take a schema with products, orders, and promotions:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_items&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;price_cents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_item_promotions&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_item_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;promotion_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_item_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;promotion_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;promotions&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;An order item can have multiple promotions applied to it (a percentage discount stacked with a free shipping promo). Query: total revenue, broken down by promotion:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_items&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_item_promotions&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oip&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_item_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;promotions&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oip&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;promotion_id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;p&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;If an order item had two promotions, its &lt;code&gt;price_cents&lt;/code&gt; shows up twice (once under each promotion). Sum those up and total revenue exceeds actual revenue. Worse, if you then compare &amp;ldquo;sum across all promotions&amp;rdquo; to &amp;ldquo;total revenue from order_items,&amp;rdquo; the numbers don&amp;rsquo;t tie out, and there&amp;rsquo;s no obvious reason why.&lt;/p&gt;
&lt;p&gt;The bridge table is doing exactly what it&amp;rsquo;s supposed to do. The query is doing exactly what the SQL says. The meaning of the aggregation drifts as soon as you cross a many-to-many boundary.&lt;/p&gt;
&lt;h3 id="a-related-trap-date-filters-across-joined-tables"&gt;A related trap: date filters across joined tables
&lt;/h3&gt;&lt;p&gt;A variation of the grain problem shows up in schemas where related tables each carry their own independently-moving date column: orders vs. shipments, subscriptions vs. invoices, tickets vs. updates, orders vs. returns. When a question is time-bounded (&amp;ldquo;Q1 revenue from items shipped in Q1&amp;rdquo;), the date filter has to land on the column that matches the question. Filtering on both tables &amp;ldquo;to be safe&amp;rdquo; silently excludes rows whose dates diverge. An order placed in December with items shipping in January is a Q1 shipment; a filter on &lt;code&gt;orders.created_at&lt;/code&gt; throws it out.&lt;/p&gt;
&lt;p&gt;The rule is the same as for row multiplication: pick the grain that matches the question, once. If the question is about shipments, filter on &lt;code&gt;shipped_at&lt;/code&gt;. If it&amp;rsquo;s about orders, filter on &lt;code&gt;created_at&lt;/code&gt;. Combining both feels more rigorous and quietly returns the wrong set.&lt;/p&gt;
&lt;h2 id="how-to-diagnose-it"&gt;How to diagnose it
&lt;/h2&gt;&lt;p&gt;The symptom is always the same: a number that doesn&amp;rsquo;t match what another system says it should be. Revenue doesn&amp;rsquo;t match billing. User counts don&amp;rsquo;t match the auth service. Item totals don&amp;rsquo;t match inventory. When that happens, the first thing to check isn&amp;rsquo;t the aggregation; it&amp;rsquo;s the row count at each stage of the query.&lt;/p&gt;
&lt;p&gt;Take the aggregation off and see what you&amp;rsquo;re actually summing:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Original (wrong) query
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_items&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Diagnostic: see the raw rows for one customer
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_cents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;item_id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_items&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ORDER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;If the same &lt;code&gt;order_id&lt;/code&gt; and &lt;code&gt;total_cents&lt;/code&gt; appear on multiple rows, the sum is going to double-count. Seeing the raw rows makes the multiplication obvious in a way the aggregated output never does.&lt;/p&gt;
&lt;p&gt;Another useful check: compare counts at each level independently.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Count orders directly
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&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="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Returns: 3
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Count orders through the joined query
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&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="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_items&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Returns: 12
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- The 4x multiplication is the join&amp;#39;s cardinality
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;When the two numbers don&amp;rsquo;t match, the join is multiplying rows. Every aggregation downstream of that join is suspect.&lt;/p&gt;
&lt;h2 id="how-to-solve-it"&gt;How to solve it
&lt;/h2&gt;&lt;p&gt;There&amp;rsquo;s no single fix; the right technique depends on whether the aggregation lives on the parent or the child, and how many cardinality boundaries you&amp;rsquo;re crossing.&lt;/p&gt;
&lt;h3 id="aggregate-at-the-correct-grain-then-join"&gt;Aggregate at the correct grain, then join
&lt;/h3&gt;&lt;p&gt;The cleanest approach is usually to do each aggregation at the table where the data actually lives, then join the pre-aggregated results together. This keeps row counts under control and makes the query&amp;rsquo;s intent obvious.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;span class="lnt"&gt;13
&lt;/span&gt;&lt;span class="lnt"&gt;14
&lt;/span&gt;&lt;span class="lnt"&gt;15
&lt;/span&gt;&lt;span class="lnt"&gt;16
&lt;/span&gt;&lt;span class="lnt"&gt;17
&lt;/span&gt;&lt;span class="lnt"&gt;18
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WITH&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_stats&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;total_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="n"&gt;item_stats&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COUNT&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;items_purchased&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_items&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;item_stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;items_purchased&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LEFT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_stats&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LEFT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;item_stats&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;item_stats&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Revenue is summed from &lt;code&gt;orders&lt;/code&gt; where it lives, once per order. Items are counted through the &lt;code&gt;orders&lt;/code&gt;→&lt;code&gt;order_items&lt;/code&gt; join separately. Then both are joined back to &lt;code&gt;customers&lt;/code&gt;. Each aggregation happens at its correct grain, and the final join is 1:1:1, no multiplication.&lt;/p&gt;
&lt;p&gt;It looks more verbose. It is. That&amp;rsquo;s the point. The verbosity is making the cardinality explicit instead of hiding it behind a single flat join.&lt;/p&gt;
&lt;h3 id="use-distinct-inside-the-aggregate-with-caution"&gt;Use &lt;code&gt;DISTINCT&lt;/code&gt; inside the aggregate, with caution
&lt;/h3&gt;&lt;p&gt;When the multiplication is already there, &lt;code&gt;SUM(DISTINCT ...)&lt;/code&gt; can sometimes paper over it:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;DISTINCT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;revenue&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- suspicious
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customers&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_items&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;GROUP&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;c&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;This only works if &lt;code&gt;total_cents&lt;/code&gt; is guaranteed to be unique across the duplicated rows. If two different orders happen to have the same total, &lt;code&gt;DISTINCT&lt;/code&gt; collapses them into one and revenue drops. It&amp;rsquo;s fragile: correct for the query but wrong for the data.&lt;/p&gt;
&lt;p&gt;&lt;code&gt;COUNT(DISTINCT o.id)&lt;/code&gt; is safer because &lt;code&gt;id&lt;/code&gt; is always unique by definition. Use &lt;code&gt;DISTINCT&lt;/code&gt; on natural keys, not on aggregated values.&lt;/p&gt;
&lt;h3 id="window-functions-for-per-parent-aggregates"&gt;Window functions for &amp;ldquo;per parent&amp;rdquo; aggregates
&lt;/h3&gt;&lt;p&gt;When you need a running or per-group aggregate without collapsing rows, window functions keep the row count intact and do the math within a partition:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;span class="lnt"&gt;9
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;item_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price_cents&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;OVER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;price_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;OVER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;PARTITION&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;BY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_total&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;JOIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_items&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;oi&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;No group-by, no row collapsing, totals computed at the right grain. The cost is a result set the size of &lt;code&gt;order_items&lt;/code&gt;, so use this pattern when the row-level detail is actually needed, not as a default replacement for &lt;code&gt;GROUP BY&lt;/code&gt;.&lt;/p&gt;
&lt;h3 id="lateral-joins-and-correlated-subqueries"&gt;LATERAL joins and correlated subqueries
&lt;/h3&gt;&lt;p&gt;When you need a per-row aggregate (the total for each order, or the most recent child row) a lateral join keeps the parent&amp;rsquo;s grain and evaluates the child aggregation row by row.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- PostgreSQL: LATERAL join
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;item_count&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;LATERAL&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SUM&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;price_cents&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&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="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;item_count&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_items&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;o&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;items&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;One row per order, aggregation computed inside the lateral subquery, no multiplication. This is often faster than joining and then grouping, especially when &lt;code&gt;orders&lt;/code&gt; is heavily filtered and &lt;code&gt;order_items&lt;/code&gt; is large.&lt;/p&gt;
&lt;h2 id="schema-level-defenses"&gt;Schema-level defenses
&lt;/h2&gt;&lt;p&gt;Query-level fixes only work if the person writing the query knows to apply them. Schema-level guarantees work for every query, forever.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Foreign keys&lt;/strong&gt; tell the query planner about cardinality. PostgreSQL in particular uses FK metadata to make join-order decisions and to eliminate redundant joins during planning. Beyond the integrity benefits, FKs make the shape of the data visible to both humans and the planner. (&lt;a class="link" href="https://explainanalyze.com/p/foreign-keys-are-not-optional/#the-happy-path-isnt-the-only-path" &gt;Foreign Keys Are Not Optional&lt;/a&gt; goes deeper on why skipping them compounds into silent corruption over time.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Unique constraints on bridge tables&lt;/strong&gt; prevent accidental many-to-many explosions. A bridge table with &lt;code&gt;PRIMARY KEY (a_id, b_id)&lt;/code&gt; can&amp;rsquo;t contain duplicates, so joining through it can&amp;rsquo;t multiply rows because of duplicate bridge entries (only because of legitimate N:M relationships).&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_item_promotions&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_item_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_items&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;promotion_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;promotions&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_item_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;promotion_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- prevents duplicates
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;Without that composite primary key, a bug in the application layer that inserts the same &lt;code&gt;(order_item_id, promotion_id)&lt;/code&gt; pair twice would silently double revenue for that item in any query joining through the bridge. With it, the database rejects the duplicate at write time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Schema comments&lt;/strong&gt; on tables and columns document the cardinality and semantics that aren&amp;rsquo;t visible from the DDL. A line like &lt;code&gt;COMMENT ON TABLE order_item_promotions IS 'N:M bridge. One row per (item, promotion). Joining this multiplies order_item rows by avg promotions-per-item.'&lt;/code&gt; tells every future engineer exactly what the table does to row counts. (&lt;a class="link" href="https://explainanalyze.com/p/comment-your-schema/#what-schema-comments-are" &gt;Comment Your Schema&lt;/a&gt; covers the mechanics across MySQL and PostgreSQL and why this metadata layer is almost always empty.)&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Denormalized totals, when the trade-off is worth it.&lt;/strong&gt; For heavily queried aggregates (order totals, user balance, post comment counts), storing the aggregate on the parent table eliminates the join entirely. The write-path cost is keeping the denormalized value consistent: either through application code, triggers, or scheduled reconciliation. For high-read, low-write aggregates, the read simplicity often wins. For everything else, computing on demand is cleaner.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Denormalization has its own failure mode&lt;/strong&gt;
 &lt;div&gt;A stored &lt;code&gt;orders.total_cents&lt;/code&gt; that&amp;rsquo;s out of sync with &lt;code&gt;SUM(order_items.price_cents)&lt;/code&gt; is its own form of silent corruption, moved from the query layer to the write layer. Either invest in keeping it consistent (triggers, reconciliation jobs) or don&amp;rsquo;t denormalize it at all. A half-maintained denormalized aggregate is worse than no denormalization.&lt;/div&gt;
&lt;/div&gt;

&lt;h2 id="the-pause-that-schema-reading-assistants-dont-take"&gt;The pause that schema-reading assistants don&amp;rsquo;t take
&lt;/h2&gt;&lt;p&gt;A schema-reading assistant asked for &amp;ldquo;total revenue by customer&amp;rdquo; reads the catalog, finds the chain of tables it needs, writes the JOINs, adds the SUM, and hands back a query that looks right. The pause described in the section below (&amp;ldquo;wait, does this join multiply rows?&amp;rdquo;) is a step the model doesn&amp;rsquo;t take unless the prompt asks for it. The catalog tells the assistant that &lt;code&gt;customers&lt;/code&gt;, &lt;code&gt;orders&lt;/code&gt;, &lt;code&gt;order_items&lt;/code&gt;, and the &lt;code&gt;order_item_promotions&lt;/code&gt; bridge exist; it doesn&amp;rsquo;t tell it that joining through the bridge duplicates every &lt;code&gt;order_items&lt;/code&gt; row once per promotion. The inflated total and the correct one look the same on the way back.&lt;/p&gt;
&lt;p&gt;The same schema-level defenses that help humans give the model more to work with. FK metadata lets a catalog-reading tool see which joins are 1:N versus N:M. Composite primary keys on bridge tables prevent the &amp;ldquo;duplicate-in-bridge&amp;rdquo; multiplier from ever materializing in the data. Table comments that spell out cardinality (something like &lt;code&gt;'N:M bridge. Joining this multiplies order_item rows by avg promotions-per-item.'&lt;/code&gt;) put the warning in the part of the schema the assistant actually reads. This doesn&amp;rsquo;t replace the pause described below; it narrows the set of cases where the pause has to do all the work.&lt;/p&gt;
&lt;h2 id="the-mental-model"&gt;The mental model
&lt;/h2&gt;&lt;p&gt;The shortcut that prevents most of these bugs: before writing an aggregation, picture the row count at every stage of the query.&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;Start with the leftmost table. How many rows?&lt;/li&gt;
&lt;li&gt;Each join: does this multiply, preserve, or filter the row count?&lt;/li&gt;
&lt;li&gt;At the point where the aggregate runs: what is the grain of each row? What does &amp;ldquo;one row&amp;rdquo; represent?&lt;/li&gt;
&lt;li&gt;Does the aggregate make sense at that grain?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;When the answer is &amp;ldquo;one row represents an order item, but I&amp;rsquo;m summing an order-level field,&amp;rdquo; the bug is already obvious. When the answer is &amp;ldquo;one row represents an order, and I&amp;rsquo;m summing order totals,&amp;rdquo; the query is correct.&lt;/p&gt;
&lt;p&gt;This isn&amp;rsquo;t a skill that scales with query complexity; it&amp;rsquo;s a habit that kicks in before the query gets written. The senior engineers who never seem to hit these bugs aren&amp;rsquo;t writing smarter SQL. They&amp;rsquo;re pausing before the &lt;code&gt;SUM&lt;/code&gt; and asking what row they&amp;rsquo;re actually summing over.&lt;/p&gt;
&lt;h2 id="putting-it-together"&gt;Putting it together
&lt;/h2&gt;&lt;p&gt;Cardinality bugs are a specific kind of wrong: syntactically valid, semantically broken, and invisible to every automated check. Tests pass. Code reviews approve. Reports render. The numbers just happen to be wrong.&lt;/p&gt;
&lt;p&gt;The defense is structural, not tactical. Understand the cardinality of each relationship before writing the join. Aggregate at the grain where the data lives. Use the schema to make cardinality explicit: foreign keys, composite primary keys on bridges, comments that document the shape. When diagnosing a wrong number, strip the aggregation and look at the raw rows; the multiplication is almost always visible as soon as the &lt;code&gt;SUM&lt;/code&gt; is out of the way.&lt;/p&gt;
&lt;p&gt;The worst thing about silent bugs is that they stay silent. A crash gets fixed; wrong numbers persist for quarters. The habit of thinking about cardinality first (before writing the aggregation, not after someone flags the total) is one of the highest-leverage habits in working with relational data.&lt;/p&gt;</description></item><item><title>Uniqueness and Selectivity: The Two Numbers That Drive Query Plans</title><link>https://explainanalyze.com/p/uniqueness-and-selectivity-the-two-numbers-that-drive-query-plans/</link><pubDate>Mon, 23 Dec 2024 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/uniqueness-and-selectivity-the-two-numbers-that-drive-query-plans/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Uniqueness and Selectivity: The Two Numbers That Drive Query Plans" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;Uniqueness governs correctness, selectivity governs performance. The interesting parts of both live in the edge cases: partial unique indexes and their UPSERT targeting quirks, the way partitioning weakens every uniqueness guarantee, correlated columns that defeat planner assumptions, stale statistics that turn a 5ms query into a 5-minute one. Declaring the constraints the planner can see and keeping its statistics fresh buys more than any amount of query rewriting.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;Everyone who works with relational databases knows &lt;code&gt;UNIQUE&lt;/code&gt;. What they often don&amp;rsquo;t know is how it behaves under partitioning, how &lt;code&gt;ON CONFLICT&lt;/code&gt; targets it (and doesn&amp;rsquo;t), and what the planner actually does with it beyond rejecting duplicates. Selectivity is in the same category. The definition is trivial, but the behavior that matters lives in composite column ordering, stale statistics, and the correlated-columns problem that breaks the planner&amp;rsquo;s core assumption.&lt;/p&gt;
&lt;p&gt;This is the territory where &amp;ldquo;the query is correct&amp;rdquo; and &amp;ldquo;the query is fast&amp;rdquo; stop being the same question, and both depend on what the database can actually prove about the data. The constraints are the contract between the schema and the planner. Everything else is inference.&lt;/p&gt;
&lt;h2 id="partial-and-filtered-unique-indexes"&gt;Partial and filtered unique indexes
&lt;/h2&gt;&lt;p&gt;PostgreSQL supports partial unique indexes: uniqueness enforced only over rows matching a predicate. This is the right tool for the common real-world case &amp;ldquo;email must be unique among active users&amp;rdquo;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- PostgreSQL: email unique only among non-soft-deleted rows.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INDEX&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users_active_email_uniq&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;deleted_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;A plain &lt;code&gt;UNIQUE (email)&lt;/code&gt; forces a choice: either allow re-registration (and lose referential integrity by reusing emails across deleted and active rows) or block it (and frustrate users whose accounts were long ago soft-deleted). The partial index lets both coexist.&lt;/p&gt;
&lt;p&gt;MySQL doesn&amp;rsquo;t support partial unique indexes directly. The workaround exploits MySQL&amp;rsquo;s treatment of NULL as distinct under &lt;code&gt;UNIQUE&lt;/code&gt; (covered in &lt;a class="link" href="https://explainanalyze.com/p/null-in-sql-three-valued-logic-and-the-silent-bug-factory/" &gt;NULL: Three-Valued Logic&lt;/a&gt;):&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- MySQL idiom: generated column that&amp;#39;s NULL for deleted users.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ADD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COLUMN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;email_active&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;VARCHAR&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;255&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;GENERATED&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ALWAYS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="k"&gt;CASE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHEN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;deleted_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;THEN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;END&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;VIRTUAL&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ADD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;UNIQUE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users_active_email_uniq&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email_active&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The constraint effectively fires only for rows where &lt;code&gt;email_active&lt;/code&gt; is non-NULL: exactly partial-index semantics, just expressed through a generated column. Awkward to write, but portable-ish and the ORMs catch on eventually.&lt;/p&gt;
&lt;h2 id="partitioned-tables-force-uniqueness-compromises"&gt;Partitioned tables force uniqueness compromises
&lt;/h2&gt;&lt;p&gt;Partitioned tables in both PostgreSQL and MySQL require the partition key to be part of every unique constraint - including the primary key. The rule exists for correctness: without the partition key in the constraint, the database would have to scan every partition on every insert to enforce uniqueness, defeating the point of partitioning.&lt;/p&gt;
&lt;p&gt;The practical consequence is that &lt;code&gt;PRIMARY KEY (id)&lt;/code&gt; isn&amp;rsquo;t allowed on a table partitioned by &lt;code&gt;created_at&lt;/code&gt;. It has to become &lt;code&gt;PRIMARY KEY (id, created_at)&lt;/code&gt;. The same applies to every other unique constraint: &lt;code&gt;UNIQUE (email)&lt;/code&gt; on a users table partitioned by region becomes &lt;code&gt;UNIQUE (email, region)&lt;/code&gt;, which quietly weakens the guarantee. The schema now allows the same email to exist in multiple regions, whether or not the application ever intended that.&lt;/p&gt;
&lt;p&gt;This is one of the sharper trade-offs in partitioning decisions. A uniqueness guarantee the schema used to provide gets weaker, and point lookups that used to be single-row &lt;code&gt;const&lt;/code&gt; accesses become &lt;code&gt;ref&lt;/code&gt; lookups because the full primary key isn&amp;rsquo;t spelled out in every query. &lt;a class="link" href="https://explainanalyze.com/p/designing-partitioning-you-dont-have-to-babysit/#what-actually-belongs-in-the-partition-key" &gt;How Partitioning Turns &lt;code&gt;WHERE id = 12345&lt;/code&gt; Into a 36-Partition Scan&lt;/a&gt; covers the full picture, including why partitioning by the primary key itself (when the PK is monotonically increasing) sidesteps the trade-off entirely.&lt;/p&gt;
&lt;h2 id="upsert-targeting-is-more-specific-than-it-looks"&gt;UPSERT targeting is more specific than it looks
&lt;/h2&gt;&lt;p&gt;&lt;code&gt;INSERT ... ON CONFLICT&lt;/code&gt; (PostgreSQL) and &lt;code&gt;INSERT ... ON DUPLICATE KEY UPDATE&lt;/code&gt; (MySQL) bind to specific unique constraints, not to &amp;ldquo;any uniqueness that happens to apply.&amp;rdquo; The difference between the two engines is where most of the subtle bugs live.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;PostgreSQL is explicit.&lt;/strong&gt; &lt;code&gt;ON CONFLICT (email)&lt;/code&gt; requires a unique constraint or unique index exactly matching &lt;code&gt;email&lt;/code&gt;. If none exists, the statement errors out. If a partial unique index exists instead of a plain one, &lt;code&gt;ON CONFLICT (email)&lt;/code&gt; does not match it; you need the full predicate:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Must match the partial index&amp;#39;s predicate to target it.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;alice@example.com&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Alice&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;CONFLICT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;deleted_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;DO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;UPDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SET&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;EXCLUDED&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;If the partial index changes (predicate tightened, column added), every &lt;code&gt;ON CONFLICT&lt;/code&gt; targeting it has to change too. This is explicit coupling, but it&amp;rsquo;s coupling.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;MySQL is implicit and more dangerous.&lt;/strong&gt; &lt;code&gt;ON DUPLICATE KEY UPDATE&lt;/code&gt; fires on conflict with any unique key on the table, not just the one the query author had in mind. If the table has &lt;code&gt;UNIQUE (email)&lt;/code&gt; and &lt;code&gt;UNIQUE (external_id)&lt;/code&gt;, an insert that conflicts on either key triggers the update. For rows where the inserted &lt;code&gt;email&lt;/code&gt; matches one existing row and the &lt;code&gt;external_id&lt;/code&gt; matches a different one, the behavior depends on which index is checked first and is undefined as far as the language is concerned.&lt;/p&gt;
&lt;p&gt;The practical implication: adding a new unique key to a table can silently change the semantics of every existing &lt;code&gt;INSERT ... ON DUPLICATE KEY UPDATE&lt;/code&gt; against that table. There&amp;rsquo;s no error, no warning, just different behavior on the next conflict that falls into the new key&amp;rsquo;s path. On large schemas with dozens of unique keys, this is the UPSERT equivalent of action at a distance.&lt;/p&gt;
&lt;p&gt;The mitigation on MySQL is to prefer &lt;code&gt;INSERT ... ON DUPLICATE KEY UPDATE&lt;/code&gt; only when there&amp;rsquo;s a single obvious unique key, and to reach for &lt;code&gt;REPLACE&lt;/code&gt; or explicit &lt;code&gt;SELECT ... FOR UPDATE&lt;/code&gt; + conditional &lt;code&gt;UPDATE&lt;/code&gt;/&lt;code&gt;INSERT&lt;/code&gt; flows when the semantics need to be explicit.&lt;/p&gt;
&lt;h2 id="unique-indexes-also-concentrate-deadlock-pressure"&gt;Unique indexes also concentrate deadlock pressure
&lt;/h2&gt;&lt;p&gt;Two deadlock patterns are specific to unique indexes and show up almost nowhere else:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Duplicate-key inserts take locks even when they fail.&lt;/strong&gt; When InnoDB detects a duplicate on insert, it doesn&amp;rsquo;t just raise the error; it first acquires a shared next-key lock on the conflicting row. Under &lt;code&gt;REPEATABLE READ&lt;/code&gt; (the default), that lock covers the gap too. Two concurrent transactions inserting near the same unique key can deadlock on those shared locks before either sees the duplicate-key error. The most common production signature: a batch-upsert worker hitting the same hot row ranges from multiple threads.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;code&gt;ON DUPLICATE KEY UPDATE&lt;/code&gt; batches deadlock when key ordering differs.&lt;/strong&gt; Each row in a batch insert acquires its lock when the row is processed, not when the batch starts. Two batches touching overlapping keys &lt;code&gt;(A, B)&lt;/code&gt; vs &lt;code&gt;(B, A)&lt;/code&gt; take locks in opposite order and cycle. The fix is either sorting rows by unique key before the batch (so lock acquisition order is consistent across workers) or switching to &lt;code&gt;INSERT ... ON CONFLICT DO NOTHING&lt;/code&gt; plus a separate targeted &lt;code&gt;UPDATE&lt;/code&gt; pass.&lt;/p&gt;
&lt;p&gt;Neither of these shows up the same way with non-unique indexes; the uniqueness check itself is what forces the extra locking. It&amp;rsquo;s the cost of making the database enforce the guarantee, and it scales badly once the hot-key set is small and write concurrency is high. (&lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-1-the-patterns/#unique-index-deadlocks-are-a-category-of-their-own" &gt;Database Deadlocks, Part 1&lt;/a&gt; covers the broader patterns; &lt;a class="link" href="https://explainanalyze.com/p/database-deadlocks-part-2-diagnosis-retries-and-prevention/#nowait-and-skip-locked-as-prevention-primitives" &gt;Part 2&lt;/a&gt; covers reading the log, retries, and prevention.)&lt;/p&gt;
&lt;h2 id="composite-index-column-ordering"&gt;Composite index column ordering
&lt;/h2&gt;&lt;p&gt;The order of columns in a composite index is a selectivity decision that determines whether the index helps the query it was built for. The usual rules compress to three:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Equality filters before range filters.&lt;/strong&gt; An index on &lt;code&gt;(customer_id, created_at)&lt;/code&gt; is efficient for &lt;code&gt;WHERE customer_id = 42 AND created_at &amp;gt; '2026-01-01'&lt;/code&gt;. Reversed (&lt;code&gt;(created_at, customer_id)&lt;/code&gt;), the index has to scan a wide range of &lt;code&gt;created_at&lt;/code&gt; values and filter &lt;code&gt;customer_id&lt;/code&gt; as a secondary step, which is usually worse than a sequential scan.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;More selective column first for equality-only predicates.&lt;/strong&gt; For filters of the form &lt;code&gt;WHERE a = ? AND b = ?&lt;/code&gt;, the column with more distinct values goes first so the first lookup narrows more aggressively.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Match the query&amp;rsquo;s access pattern.&lt;/strong&gt; An index on &lt;code&gt;(a, b, c)&lt;/code&gt; serves queries filtering by &lt;code&gt;a&lt;/code&gt;, &lt;code&gt;(a, b)&lt;/code&gt;, or &lt;code&gt;(a, b, c)&lt;/code&gt;. It does not serve queries filtering by &lt;code&gt;b&lt;/code&gt; alone, &lt;code&gt;c&lt;/code&gt; alone, or &lt;code&gt;(b, c)&lt;/code&gt;. The leading column is load-bearing.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;These interact with covering index considerations, sort order requirements, and the planner&amp;rsquo;s ability to combine multiple indexes via bitmap scans. But the starting point is: think about how the index will be read, not what columns are available to throw at it.&lt;/p&gt;
&lt;h3 id="mysql-clustered-indexes-flip-the-rule"&gt;MySQL clustered indexes flip the rule
&lt;/h3&gt;&lt;p&gt;The above applies cleanly to secondary indexes. The MySQL InnoDB primary key is a different animal: a clustered index, meaning the PK&amp;rsquo;s leaf pages are the table. The ordering of PK columns decides physical row order on disk, and that often matters more than selectivity.&lt;/p&gt;
&lt;p&gt;The canonical example is &lt;code&gt;PRIMARY KEY (tenant_id, id)&lt;/code&gt; on a multi-tenant table. &lt;code&gt;tenant_id&lt;/code&gt; has maybe 10K distinct values (low selectivity); &lt;code&gt;id&lt;/code&gt; is near-unique. By &amp;ldquo;most selective first,&amp;rdquo; the answer would be &lt;code&gt;(id, tenant_id)&lt;/code&gt;, and it would be wrong:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Physical clustering.&lt;/strong&gt; All rows for one tenant sit contiguously in the B-tree. Tenant-scoped range scans read a narrow slice of pages sequentially, and the buffer pool caches a single tenant&amp;rsquo;s hot data together. &lt;code&gt;(id, tenant_id)&lt;/code&gt; scatters that same tenant&amp;rsquo;s rows across the whole table.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Secondary index lookups cost less.&lt;/strong&gt; InnoDB secondary indexes store the PK, not a row pointer. A query that uses a secondary index and then needs a full row does a PK lookup per match. With &lt;code&gt;(tenant_id, id)&lt;/code&gt;, those lookups for one tenant cluster together. With &lt;code&gt;(id, tenant_id)&lt;/code&gt;, each is random I/O across the table.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Insert locality.&lt;/strong&gt; If &lt;code&gt;id&lt;/code&gt; is monotonically increasing within a tenant, inserts land on recent pages per tenant, avoiding page splits scattered across the index.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;The rule for an InnoDB PK is: put the column that represents the dominant access pattern first, even if it&amp;rsquo;s less selective. Selectivity cuts rows; clustering cuts I/O. On a large clustered index, I/O usually dominates.&lt;/p&gt;
&lt;p&gt;This is also why &lt;code&gt;PRIMARY KEY (id)&lt;/code&gt; plus &lt;code&gt;INDEX (tenant_id)&lt;/code&gt; on a multi-tenant table is often slower than &lt;code&gt;PRIMARY KEY (tenant_id, id)&lt;/code&gt;; the secondary index forces a PK-lookup hop on every read that the clustered choice avoids entirely.&lt;/p&gt;
&lt;p&gt;PostgreSQL&amp;rsquo;s primary key is a separate B-tree unique index, not clustered (a &lt;code&gt;CLUSTER&lt;/code&gt; command exists but isn&amp;rsquo;t maintained as rows are inserted), so the ordering logic there stays closer to the secondary-index rules.&lt;/p&gt;
&lt;h2 id="the-planner-doesnt-read-the-data---it-reads-statistics"&gt;The planner doesn&amp;rsquo;t read the data - it reads statistics
&lt;/h2&gt;&lt;p&gt;The planner&amp;rsquo;s entire decision-making process rests on statistics that summarize the data, not the data itself. PostgreSQL&amp;rsquo;s per-column statistics live in &lt;code&gt;pg_stats&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;span class="lnt"&gt;9
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;attname&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&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="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- estimated distinct values (negative means fraction)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;null_frac&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- fraction of rows that are NULL
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;most_common_vals&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- top values by frequency
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;most_common_freqs&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- corresponding frequencies
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;histogram_bounds&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="c1"&gt;-- distribution of non-common values
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;pg_stats&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;tablename&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;orders&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;attname&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;customer_id&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;MySQL exposes similar information through &lt;code&gt;information_schema.STATISTICS&lt;/code&gt; and &lt;code&gt;INNODB_TABLESTATS&lt;/code&gt;, though less granularly than PostgreSQL&amp;rsquo;s statistics. MySQL lacks per-column histograms on most versions (8.0+ has optional histograms, off by default).&lt;/p&gt;
&lt;p&gt;These statistics are gathered by explicit &lt;code&gt;ANALYZE&lt;/code&gt; in PostgreSQL and maintained automatically by InnoDB in MySQL. They go stale between runs. A table that was analyzed at 10M rows and is now 200M rows has planner statistics that no longer reflect reality. Join reorderings based on those estimates are decisions made on outdated data.&lt;/p&gt;
&lt;p&gt;The usual symptom is a query that was fast yesterday and slow today, with no schema or query change. The planner&amp;rsquo;s row estimate for some step has drifted far enough from reality that the plan shape flipped: nested loop where it should have been hash join, or a sequential scan where an index seek would have won. &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; with its estimated-vs-actual row counts is the fastest way to confirm this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;customer_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Index Scan using orders_customer_id_idx
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- (cost=0.43..1234.56 rows=1000 width=128)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- (actual time=0.123..45.678 rows=180000 loops=1)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The &lt;code&gt;rows=1000&lt;/code&gt; is the estimate. The &lt;code&gt;actual rows=180000&lt;/code&gt; is reality. A ratio of 100x+ between them is the signal. The fix is statistical (refresh stats, increase the statistics target for that column, add extended statistics for correlated columns) and not a query rewrite.&lt;/p&gt;
&lt;h2 id="cardinality-estimation-errors-and-their-shape"&gt;Cardinality estimation errors and their shape
&lt;/h2&gt;&lt;p&gt;The single most common cause of bad query plans in production is a bad row-count estimate on an intermediate step. Two flavors, each with distinctive symptoms:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Underestimates.&lt;/strong&gt; The planner thinks a step will return 10 rows, actually returns 10 million. The plan picks a nested loop (good for a small outer side), which now runs 10 million iterations. A query that should have been a 50ms hash join takes 50 minutes. The telltale sign in &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; is &lt;code&gt;loops=10000000&lt;/code&gt; on an inner node that was costed for a handful.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Overestimates.&lt;/strong&gt; The planner thinks 10 million rows, actually 10. The plan allocates a hash table sized for millions, spills to disk under memory pressure, and runs a 5ms lookup in 5 seconds. Less common but more insidious, because the query didn&amp;rsquo;t &amp;ldquo;fail&amp;rdquo; in any obvious way; it just used more memory and I/O than it needed.&lt;/p&gt;
&lt;p&gt;Both are failures of the statistics, not the query. Both are especially hard to diagnose because the query text is identical in the fast and slow cases; only the planner&amp;rsquo;s belief about the data changed. When the ratio between estimated and actual is large and consistent, the problem is upstream of the query.&lt;/p&gt;
&lt;h2 id="correlated-columns-break-the-independence-assumption"&gt;Correlated columns break the independence assumption
&lt;/h2&gt;&lt;p&gt;The planner estimates the selectivity of a compound predicate &lt;code&gt;WHERE a = x AND b = y&lt;/code&gt; by multiplying the individual selectivities, assuming the columns are statistically independent. When they&amp;rsquo;re not, the estimate can be off by orders of magnitude.&lt;/p&gt;
&lt;p&gt;The canonical example is &lt;code&gt;(country, state)&lt;/code&gt;:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;addresses&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;US&amp;#39;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;CA&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Estimate: (0.25) * (0.02) * N = 0.5% of rows
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Reality: ~2% of rows - state = &amp;#39;CA&amp;#39; implies country = &amp;#39;US&amp;#39;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The planner assumed the two filters cut the rowcount independently. In reality, &lt;code&gt;state = 'CA'&lt;/code&gt; already determines &lt;code&gt;country = 'US'&lt;/code&gt; (there are no California rows with a different country) so the compound filter isn&amp;rsquo;t as selective as the multiplication suggests.&lt;/p&gt;
&lt;p&gt;PostgreSQL 10+ supports extended statistics to fix this:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;STATISTICS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;country_state_corr&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;dependencies&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;ndistinct&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;country&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;state&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;addresses&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;addresses&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;The &lt;code&gt;dependencies&lt;/code&gt; statistic captures functional dependencies (one column determines another); &lt;code&gt;ndistinct&lt;/code&gt; captures the distinct combinations of the column set. Both are used during planning to correct the independence-assumption multiplication.&lt;/p&gt;
&lt;p&gt;MySQL has no equivalent. Correlated-column estimation errors there are harder to fix at the planner level; the workaround is usually to restructure the query (force a specific join order, introduce an intermediate CTE, or add a covering index that captures the correlated access pattern directly).&lt;/p&gt;
&lt;h2 id="unique-as-a-planner-signal-not-just-a-guardrail"&gt;UNIQUE as a planner signal, not just a guardrail
&lt;/h2&gt;&lt;p&gt;A &lt;code&gt;UNIQUE&lt;/code&gt; constraint is also a proof the planner can use. Knowing a column is unique lets the optimizer reason about the shape of joins and aggregates in ways it can&amp;rsquo;t when uniqueness is only implicit:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Deduplication elimination.&lt;/strong&gt; &lt;code&gt;SELECT DISTINCT u.id FROM users u JOIN orders o ON o.user_id = u.id&lt;/code&gt; can skip the &lt;code&gt;DISTINCT&lt;/code&gt; step entirely if the planner knows &lt;code&gt;users.id&lt;/code&gt; is unique. The join already produces at most one row per &lt;code&gt;u.id&lt;/code&gt; per matching order, and the &lt;code&gt;DISTINCT&lt;/code&gt; becomes a no-op. Without the declared uniqueness, the planner has to run the dedup pass.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Join elimination.&lt;/strong&gt; When joining A to B on a unique column of B, and selecting only columns from A, the planner can drop the join entirely in some cases (it proved the join doesn&amp;rsquo;t change the output). This is a real optimization on star-schema queries.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Reorderable joins.&lt;/strong&gt; Unique constraints make certain join orderings provably equivalent, giving the optimizer more plan shapes to choose from. The more plans it can try, the more likely it finds a good one.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Index-only scan eligibility.&lt;/strong&gt; Unique indexes are natural targets for index-only scans, which skip the heap/table access when every column the query needs is already in the index.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Schemas that leave uniqueness implicit (enforced in application code, promised in a wiki) can still produce correct results, but the planner can&amp;rsquo;t trust assumptions it can&amp;rsquo;t see. The constraint is what turns uniqueness from a property of the data into a property of the schema that the planner reads as a fact.&lt;/p&gt;
&lt;h2 id="what-unique-tells-a-schema-reading-model"&gt;What UNIQUE tells a schema-reading model
&lt;/h2&gt;&lt;p&gt;The planner isn&amp;rsquo;t the only consumer of declared uniqueness. Schema-reading assistants (Copilot, MCP-backed agents, text-to-SQL tools) read &lt;code&gt;information_schema.TABLE_CONSTRAINTS&lt;/code&gt; and &lt;code&gt;pg_constraint&lt;/code&gt; the same way they read column types. A declared &lt;code&gt;UNIQUE&lt;/code&gt; is the only signal in the catalog that says &amp;ldquo;at most one row per X.&amp;rdquo; Without it, the model has no way to prove 1:1 semantics and either hedges with a defensive &lt;code&gt;LIMIT 1&lt;/code&gt; it can&amp;rsquo;t justify or writes &lt;code&gt;GROUP BY&lt;/code&gt; / &lt;code&gt;DISTINCT&lt;/code&gt; passes that shouldn&amp;rsquo;t be necessary. &lt;code&gt;ON CONFLICT&lt;/code&gt; and &lt;code&gt;ON DUPLICATE KEY UPDATE&lt;/code&gt; targeting is especially fragile: the model picks the column name that matches the prompt (&amp;ldquo;upsert by email&amp;rdquo;) and the query either fails at runtime because no unique constraint exists on that column, or silently targets a different constraint than intended.&lt;/p&gt;
&lt;p&gt;Selectivity is the part the model has even less access to. Planner statistics (&lt;code&gt;pg_stats.n_distinct&lt;/code&gt;, MySQL&amp;rsquo;s &lt;code&gt;information_schema.STATISTICS&lt;/code&gt; cardinality estimates) aren&amp;rsquo;t part of the prompt for most schema-aware tools, and the model has no way to query them mid-generation. Asked &amp;ldquo;how do I speed this query up?&amp;rdquo; the assistant&amp;rsquo;s default answer is &amp;ldquo;add an index,&amp;rdquo; regardless of whether the indexed column has two distinct values or two million. The same schema discipline that keeps the planner honest (declared unique constraints on every at-most-one relationship, composite primary keys on bridge tables, column-level comments that describe the value shape) is what gives catalog-reading models enough context to produce queries that don&amp;rsquo;t require a second human pass.&lt;/p&gt;
&lt;h2 id="diagnosing-the-usual-suspects"&gt;Diagnosing the usual suspects
&lt;/h2&gt;&lt;p&gt;Three patterns cover most of the uniqueness/selectivity-shaped bugs in production:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;ldquo;This query got slow and nothing changed.&amp;rdquo;&lt;/strong&gt; Run &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt;. Compare estimated to actual row counts on each node. A large ratio (10x+) means the planner has stale statistics, missing extended statistics on correlated columns, or both. Refresh stats with &lt;code&gt;ANALYZE&lt;/code&gt;; add extended statistics if a compound predicate is the source.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;ldquo;I built an index and the planner ignores it.&amp;rdquo;&lt;/strong&gt; Check the column&amp;rsquo;s selectivity directly: distinct values over total. Below ~5%, a sequential scan is usually the right choice and the planner isn&amp;rsquo;t wrong. If selectivity is high, check for functions in the &lt;code&gt;WHERE&lt;/code&gt; clause (non-SARGable predicates), implicit type casts (an indexed &lt;code&gt;BIGINT&lt;/code&gt; column filtered with a &lt;code&gt;VARCHAR&lt;/code&gt; literal can fall off the index), or stale statistics underreporting the column&amp;rsquo;s uniqueness.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&amp;ldquo;My UPSERT corrupts data under load.&amp;rdquo;&lt;/strong&gt; Check which unique key it&amp;rsquo;s targeting. In MySQL, &lt;code&gt;ON DUPLICATE KEY UPDATE&lt;/code&gt; fires on conflict with any unique key, including ones added after the query was written. In PostgreSQL, partial unique indexes require the predicate in &lt;code&gt;ON CONFLICT&lt;/code&gt;; mismatches silently fall through to insert rather than update.&lt;/p&gt;
&lt;h2 id="the-mental-model"&gt;The mental model
&lt;/h2&gt;&lt;p&gt;Uniqueness and selectivity collapse to two questions that both the planner and the engineer need answered for every table and query:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;How many rows per key?&lt;/strong&gt; Uniqueness. Determines whether joins multiply, whether UPSERTs target the right constraint, and whether aggregations can be trusted.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;How many distinct values relative to total?&lt;/strong&gt; Selectivity. Determines whether indexes help, which join order the planner picks, and how badly a compound filter will miss.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Both answers are visible to the planner if the constraints are declared and the statistics are current. Both become guesswork when they&amp;rsquo;re not. The habit that pays off isn&amp;rsquo;t heroic query tuning. It&amp;rsquo;s keeping the database&amp;rsquo;s model of the data honest: declare the unique constraints that exist (including composite ones on bridge tables), refresh statistics on busy tables, add extended statistics where correlation has burned you before, and read &lt;code&gt;EXPLAIN ANALYZE&lt;/code&gt; for the ratio between estimated and actual rows every time a query slows down.&lt;/p&gt;</description></item><item><title>Comment Your Schema</title><link>https://explainanalyze.com/p/comment-your-schema/</link><pubDate>Mon, 18 Nov 2024 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/comment-your-schema/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Comment Your Schema" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;Every major database engine lets you attach comments to tables and columns: descriptions that live in the schema itself and show up in every tool that reads it. They cost nothing to add, require no downtime, and make every schema dump, ER diagram, and monitoring tool more useful. Almost nobody uses them.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;A new engineer is debugging a customer support ticket: &amp;ldquo;order #4421 shows up as &amp;lsquo;Failed&amp;rsquo; in the admin tool but the customer received it.&amp;rdquo; She opens the &lt;code&gt;orders&lt;/code&gt; table in DataGrip and finds &lt;code&gt;status TINYINT NOT NULL&lt;/code&gt;. The admin tool displays &amp;ldquo;Failed&amp;rdquo; when status = 2. The fulfillment service ships when status = 3. The reporting view treats status = 1 as &amp;ldquo;active.&amp;rdquo; None of the three definitions are in the schema, and nobody on the team remembers the original mapping; the engineer who designed the table left eight months ago.&lt;/p&gt;
&lt;p&gt;Resolving the ticket takes ninety minutes: grep three service codebases, find three different mappings, reconcile them against the actual row&amp;rsquo;s status = 2, draft the customer email. Every part of that work happens because the integer values aren&amp;rsquo;t grounded anywhere the database knows about, and the code&amp;rsquo;s three guesses disagree. The mechanism that would have grounded them in the catalog has existed in every major database engine since the 1990s. The team has just never written it down.&lt;/p&gt;
&lt;h2 id="what-schema-comments-are"&gt;What schema comments are
&lt;/h2&gt;&lt;p&gt;Schema comments are metadata strings attached directly to database objects: tables, columns, indexes, views. They&amp;rsquo;re stored in the database catalog and exposed through standard metadata queries.&lt;/p&gt;
&lt;p&gt;In PostgreSQL:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Customer purchase orders. One row per checkout.&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COLUMN&lt;/span&gt;&lt;span class="w"&gt; &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="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;1=pending, 2=processing, 3=shipped, 4=delivered, 5=cancelled&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COLUMN&lt;/span&gt;&lt;span class="w"&gt; &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;end_date&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;NULL means order is still in progress&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;In MySQL:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;MODIFY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COLUMN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TINYINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;1=pending, 2=processing, 3=shipped, 4=delivered, 5=cancelled&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Or at table creation:
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;AUTO_INCREMENT&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;References users.id&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TINYINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;1=pending, 2=processing, 3=shipped, 4=delivered, 5=cancelled&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;total_cents&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;BIGINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Order total in cents, not dollars&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;end_date&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;DATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;DEFAULT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;NULL = order still in progress&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;Customer purchase orders. One row per checkout.&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;SQL Server uses extended properties, Oracle uses &lt;code&gt;COMMENT ON&lt;/code&gt;. The syntax varies. The concept is universal.&lt;/p&gt;
&lt;h2 id="where-comments-show-up"&gt;Where comments show up
&lt;/h2&gt;&lt;p&gt;This is the part that makes comments more useful than a wiki page or a README. Because they live in the catalog, they propagate automatically to every tool that reads schema metadata.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Schema dumps.&lt;/strong&gt; &lt;code&gt;pg_dump&lt;/code&gt; and &lt;code&gt;mysqldump&lt;/code&gt; include comments in the output. Anyone restoring a backup or reviewing a migration gets the context without looking elsewhere.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;ER diagram tools.&lt;/strong&gt; DBeaver, DataGrip, pgAdmin, MySQL Workbench all render column comments in schema viewers and diagrams. Hover over a column and the description is right there.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;information_schema and catalog queries.&lt;/strong&gt; Any script, tool, or automation that queries metadata picks up comments for free.&lt;/p&gt;
&lt;p&gt;In PostgreSQL:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Get a table comment
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;obj_description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;orders&amp;#39;&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;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;pg_class&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Get a column comment
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;col_description&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;orders&amp;#39;&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;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Or just use psql
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="err"&gt;\&lt;/span&gt;&lt;span class="n"&gt;d&lt;/span&gt;&lt;span class="o"&gt;+&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;In MySQL:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COLUMN_NAME&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;COLUMN_COMMENT&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &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="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TABLE_SCHEMA&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;your_database&amp;#39;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;AND&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE_NAME&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;orders&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;&lt;strong&gt;ORM introspection.&lt;/strong&gt; Many ORMs and code generators that reverse-engineer models from databases will pull comments into generated code as docstrings or annotations.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Monitoring and alerting tools.&lt;/strong&gt; When an alert fires about a table or column, the comment provides immediate context without requiring someone to look up external documentation.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;CI pipelines and schema validation.&lt;/strong&gt; Linting tools can check whether new tables and columns have comments, the same way code linters check for function docstrings.&lt;/p&gt;
&lt;p&gt;The point is that comments flow through the entire toolchain. You write them once, in one place, and every tool that reads the schema benefits, without configuration, without plugins, without maintaining anything separately.&lt;/p&gt;
&lt;h2 id="what-goes-wrong-without-them"&gt;What goes wrong without them
&lt;/h2&gt;&lt;p&gt;The absence of comments creates a category of problems that compounds over time. Onboarding takes longer than it should: every new engineer who encounters &lt;code&gt;status TINYINT&lt;/code&gt; has to ask someone or investigate. Multiply that by every ambiguous column in every table across every service, and it stops being a one-time cost. It&amp;rsquo;s paid every time someone new touches the schema.&lt;/p&gt;
&lt;p&gt;Debugging becomes archaeology. When something breaks at 2am and you&amp;rsquo;re looking at a table with columns named &lt;code&gt;type&lt;/code&gt;, &lt;code&gt;flag&lt;/code&gt;, &lt;code&gt;ref_id&lt;/code&gt;, and &lt;code&gt;config&lt;/code&gt;, all with no comments, you&amp;rsquo;re not debugging. You&amp;rsquo;re reverse-engineering institutional knowledge that should have been written down.&lt;/p&gt;
&lt;p&gt;Schema reviews lose context too. A migration that adds &lt;code&gt;is_processed TINYINT(1) DEFAULT 0&lt;/code&gt; looks fine syntactically; processed by what, when, and is it idempotent? A comment turns the review from &amp;ldquo;does this look right?&amp;rdquo; into &amp;ldquo;does this match what we agreed on?&amp;rdquo;&lt;/p&gt;
&lt;p&gt;External documentation drifts the moment someone adds a column or changes a status code without updating the doc. Comments live in the schema itself. They move with &lt;code&gt;ALTER TABLE&lt;/code&gt;. They show up in every dump. They can&amp;rsquo;t be in a different repo than the data they describe.&lt;/p&gt;
&lt;h2 id="what-to-comment"&gt;What to comment
&lt;/h2&gt;&lt;p&gt;Not everything needs a comment. A column called &lt;code&gt;created_at TIMESTAMP NOT NULL&lt;/code&gt; is self-documenting. Focus on the columns where the schema doesn&amp;rsquo;t tell the whole story:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Status and type columns.&lt;/strong&gt; What do the values mean? &lt;code&gt;1=active, 2=suspended, 3=closed&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Nullable columns where NULL has meaning.&lt;/strong&gt; Does NULL mean &amp;ldquo;not set,&amp;rdquo; &amp;ldquo;not applicable,&amp;rdquo; or &amp;ldquo;ongoing&amp;rdquo;?&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;ID columns that reference other tables without foreign keys.&lt;/strong&gt; &lt;code&gt;owner_id BIGINT COMMENT 'References users.id'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Columns with non-obvious units.&lt;/strong&gt; &lt;code&gt;total_cents&lt;/code&gt; vs &lt;code&gt;total&lt;/code&gt; (dollars? cents? units?), &lt;code&gt;duration&lt;/code&gt; (seconds? milliseconds? minutes?).&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Columns with business logic encoded in values.&lt;/strong&gt; &lt;code&gt;plan_type TINYINT COMMENT '1=free, 2=starter, 3=pro, 4=enterprise'&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Tables themselves.&lt;/strong&gt; What does this table represent? One row per what?&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;A good table comment answers: &amp;ldquo;What is one row in this table?&amp;rdquo; A good column comment answers: &amp;ldquo;What does this value mean when I see it in a query result?&amp;rdquo;&lt;/p&gt;
&lt;h2 id="adding-comments-to-an-existing-schema"&gt;Adding comments to an existing schema
&lt;/h2&gt;&lt;p&gt;This is the part that makes the cost-benefit ratio hard to argue against.&lt;/p&gt;
&lt;p&gt;In PostgreSQL, &lt;code&gt;COMMENT ON&lt;/code&gt; is a catalog-only operation. It takes no locks on the table. It doesn&amp;rsquo;t rewrite data. It doesn&amp;rsquo;t block reads or writes. On a table with 500 million rows, it completes in milliseconds.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- This is instant. No lock. No downtime. No risk.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ON&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COLUMN&lt;/span&gt;&lt;span class="w"&gt; &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="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;IS&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;1=pending, 2=processing, 3=shipped, 4=delivered, 5=cancelled&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;In MySQL, &lt;code&gt;ALTER TABLE ... MODIFY COLUMN&lt;/code&gt; with a comment is a metadata-only change in most cases with InnoDB online DDL, but behavior depends on the version and what else is in the &lt;code&gt;MODIFY&lt;/code&gt;. For comment-only changes on MySQL 8.0+, the &lt;code&gt;ALGORITHM=INSTANT&lt;/code&gt; path applies. On older versions or when combined with type changes, it may trigger a table rebuild.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- MySQL 8.0+: instant for comment-only changes
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;MODIFY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COLUMN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;status&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;TINYINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;COMMENT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;1=pending, 2=processing, 3=shipped, 4=delivered, 5=cancelled&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;div class="note-box"&gt;
 &lt;strong&gt;Zero-downtime operation&lt;/strong&gt;
 &lt;div&gt;In PostgreSQL, &lt;code&gt;COMMENT ON&lt;/code&gt; is a catalog-only update: no table lock, no rewrite, completes in milliseconds even on tables with hundreds of millions of rows. In MySQL 8.0+, comment-only changes go through the &lt;code&gt;ALGORITHM=INSTANT&lt;/code&gt; path. This is about as safe as database changes get.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;The risk profile is as close to zero as database changes get. There&amp;rsquo;s no reason not to do this incrementally; comment a few columns every time you touch a table. Over time, the schema becomes self-documenting.&lt;/p&gt;
&lt;h2 id="generating-documentation-from-comments"&gt;Generating documentation from comments
&lt;/h2&gt;&lt;p&gt;Because comments live in the catalog, tools can extract them and produce browsable documentation automatically. A few worth knowing:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a class="link" href="https://schemaspy.org/" target="_blank" rel="noopener"
 &gt;SchemaSpy&lt;/a&gt;.&lt;/strong&gt; Java-based, generates interactive HTML with ER diagrams. Reads table and column comments from the catalog. Run it against your database and you get a full documentation site with relationships, comments, and diagrams; no manual authoring.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;&lt;a class="link" href="https://github.com/k1LoW/tbls" target="_blank" rel="noopener"
 &gt;tbls&lt;/a&gt;.&lt;/strong&gt; A single Go binary, CI-friendly. Outputs Markdown, PlantUML, or SVG. Reads comments directly from the schema. Designed to run in pipelines: generate docs on every migration, commit them to the repo, and they stay in sync.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;DataGrip / DBeaver.&lt;/strong&gt; Not doc generators per se, but both render column comments inline in their schema browsers. For teams already using these, comments become immediately visible without any extra tooling.&lt;/p&gt;
&lt;p&gt;The pattern is the same across all of them: comments in the schema become descriptions in the output. No separate documentation source to maintain. The schema is the source.&lt;/p&gt;
&lt;p&gt;For teams that want generated docs as part of CI, &lt;code&gt;tbls&lt;/code&gt; is the lowest-friction option: add it to your pipeline, point it at the database, and commit the Markdown output. Every migration that adds or changes a comment updates the docs automatically.&lt;/p&gt;
&lt;h2 id="the-rag-surface-most-teams-forget"&gt;The RAG surface most teams forget
&lt;/h2&gt;&lt;p&gt;Schema-reading assistants (Copilot, MCP-backed agents, text-to-SQL tools, retrieval-augmented coding models) start with the same catalog every human does: &lt;code&gt;information_schema&lt;/code&gt;, &lt;code&gt;pg_description&lt;/code&gt;, &lt;code&gt;\d+&lt;/code&gt;. If the catalog contains only column names and types, that&amp;rsquo;s the context the model gets. A column named &lt;code&gt;status TINYINT&lt;/code&gt; is ambiguous to the model for the same reason it&amp;rsquo;s ambiguous to a new engineer, except the model won&amp;rsquo;t ping the on-call channel; it will generate a plausible query and hand it back. Published studies on text-to-SQL accuracy have put the lift from adding column-level semantic descriptions as high as ~27%, not because models are bad at reading schemas, but because most schemas don&amp;rsquo;t tell them enough to read.&lt;/p&gt;
&lt;p&gt;Comments are the one catalog field that can carry business meaning. Every other metadata row is mechanical: type, nullability, length, constraint name. A comment on &lt;code&gt;orders.status&lt;/code&gt; (&lt;code&gt;'1=pending, 2=processing, 3=shipped, 4=delivered, 5=cancelled'&lt;/code&gt;) turns a blind guess into a grounded answer for any tool that reads the catalog, human or otherwise. It&amp;rsquo;s the cheapest RAG context a team can ship: no vector store, no separate doc pipeline, no sync problem; the description travels with the column it describes. If the team is rolling out database-aware AI assistants and hasn&amp;rsquo;t commented the ambiguous columns first, the assistants are working from less context than a new hire would get on day one.&lt;/p&gt;
&lt;h2 id="making-it-stick"&gt;Making it stick
&lt;/h2&gt;&lt;p&gt;The challenge isn&amp;rsquo;t the mechanism, it&amp;rsquo;s the habit. Comments rot just like any other documentation if they&amp;rsquo;re not maintained. A few things help:&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Comment at creation time.&lt;/strong&gt; If the comment is part of the &lt;code&gt;CREATE TABLE&lt;/code&gt; or migration, it happens naturally. Retrofitting is always harder than including it from the start.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Add it to your migration template.&lt;/strong&gt; If your team uses a migration tool, add comment fields to the template. Make the absence visible rather than the default.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Lint for it.&lt;/strong&gt; A simple CI check can flag tables or columns without comments. It doesn&amp;rsquo;t have to block merges - even a warning changes behavior over time.&lt;/p&gt;
&lt;p&gt;&lt;strong&gt;Treat comments as part of the schema, not as documentation.&lt;/strong&gt; When a column&amp;rsquo;s semantics change, the comment changes in the same migration. Same PR, same review.&lt;/p&gt;
&lt;h2 id="the-trade-offs"&gt;The trade-offs
&lt;/h2&gt;&lt;p&gt;Comments aren&amp;rsquo;t a substitute for all documentation. They&amp;rsquo;re good at describing what a column or table is, not how a multi-table workflow operates. System-level documentation (data flow diagrams, service interaction maps, runbooks) still belongs somewhere else.&lt;/p&gt;
&lt;div class="warning-box"&gt;
 &lt;strong&gt;Watch out&lt;/strong&gt;
 &lt;div&gt;Stale comments are worse than no comments; they actively mislead. A column that says &lt;code&gt;'1=active, 2=inactive'&lt;/code&gt; when the code now also uses &lt;code&gt;3=suspended&lt;/code&gt; will send someone down the wrong path. Treat comment updates as part of the migration, not a follow-up task.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;There&amp;rsquo;s also a maintenance cost. Stale comments are worse than no comments because they actively mislead. A column that says &lt;code&gt;'1=active, 2=inactive'&lt;/code&gt; when the code was updated to also use &lt;code&gt;3=suspended&lt;/code&gt; will send someone down the wrong path. The mitigation is treating comments as schema, not as a nice-to-have; they change when the schema changes.&lt;/p&gt;
&lt;p&gt;For teams with hundreds of tables and thousands of columns, retrofitting comments is a slow process. It&amp;rsquo;s not a weekend project. It&amp;rsquo;s an incremental habit that pays off over months.&lt;/p&gt;
&lt;h2 id="where-to-start"&gt;Where to start
&lt;/h2&gt;&lt;p&gt;Start with the columns that make people ask questions. The status column where 0/1/2 means something nobody can quote from memory. The nullable date that means &amp;ldquo;ongoing&amp;rdquo; in one place and &amp;ldquo;missing&amp;rdquo; in another. The foreign key with no foreign key. Those are the columns where a one-line &lt;code&gt;COMMENT ON&lt;/code&gt; recovers more institutional knowledge per character than any other change a schema can absorb.&lt;/p&gt;</description></item><item><title>Foreign Keys Are Not Optional</title><link>https://explainanalyze.com/p/foreign-keys-are-not-optional/</link><pubDate>Fri, 01 Nov 2024 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/foreign-keys-are-not-optional/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post Foreign Keys Are Not Optional" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;Foreign keys are the last line of defense against orphaned data, silent corruption, and integrity issues that compound over time. Application-level validation covers the happy path; production finds every other path. The overhead is negligible; the cost of skipping them isn&amp;rsquo;t.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;A new engineer joins the team and runs &lt;code&gt;SELECT count(*) FROM order_items oi LEFT JOIN orders o ON o.id = oi.order_id WHERE o.id IS NULL&lt;/code&gt; as part of a routine schema audit. The number comes back 4,127. Four thousand line items pointing at orders that no longer exist. She asks the tech lead when this started, and the answer is &amp;ldquo;we dropped the foreign key two years ago during a launch crunch to shave write latency on the bulk import path.&amp;rdquo; The PR that did it has eleven approvals and one comment: &amp;ldquo;good catch.&amp;rdquo; The orphan cleanup will take a week, and the bigger question - what else has accumulated - will take longer.&lt;/p&gt;
&lt;p&gt;Most of the orphans trace back to two services racing to write the parent row. Service A creates the order, service B creates the line items, and B sometimes ran first when A&amp;rsquo;s deploy lagged. With the FK in place, B&amp;rsquo;s inserts would have failed and the orchestration layer would have retried. Without it, B&amp;rsquo;s inserts landed. The retries never fired. The orphans piled up at roughly six per day for two years.&lt;/p&gt;
&lt;h2 id="the-happy-path-isnt-the-only-path"&gt;The happy path isn&amp;rsquo;t the only path
&lt;/h2&gt;&lt;p&gt;Application-level validation works great when everything is running normally. When every deploy goes clean, when no one is running a backfill at 2am, when your ORM is doing exactly what you think it&amp;rsquo;s doing. Production is the set of conditions where one of those isn&amp;rsquo;t true.&lt;/p&gt;
&lt;p&gt;Every developer writing a query, a migration, or a backfill script has to carry the full mental model of the schema in their head: which tables depend on which, what breaks if a row disappears, where the implicit relationships are. Without foreign keys, that mental model is the only thing keeping the data consistent. The example writes itself.&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Looks harmless. Is it?
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;INSERT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;INTO&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_items&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;qty&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALUES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="mi"&gt;9999&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;7&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="mi"&gt;2&lt;/span&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- order 9999 doesn&amp;#39;t exist. No FK, no error. Silent corruption.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;div class="note-box"&gt;
 &lt;strong&gt;Think of it this way&lt;/strong&gt;
 &lt;div&gt;A foreign key is to data integrity what a type system is to application code: it catches mistakes at write time instead of letting them surface as bugs in production.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;With a foreign key, the database rejects this immediately, the same way a compiler catches a type error before runtime. Without one, you find out weeks or months later when a report doesn&amp;rsquo;t add up or a customer calls about missing data. By then, your backups may have already rotated out. The data is just gone.&lt;/p&gt;
&lt;p&gt;Service contracts drift the same way. Service A creates the parent record; service B creates the child. B ships a bug and starts referencing IDs that don&amp;rsquo;t exist. Without a foreign key there&amp;rsquo;s no error, just bad data accumulating quietly until someone notices the numbers don&amp;rsquo;t add up. ORMs have their own edge cases: race conditions in bulk inserts, upserts that skip association checks, lazy loading that masks broken references. Every major ORM has documented ways to let bad data slip through. The database is the one place where the check is guaranteed.&lt;/p&gt;
&lt;p&gt;This is one specific case of a broader pattern: &lt;a class="link" href="https://explainanalyze.com/p/where-business-logic-lives-database-vs.-application/" &gt;where business logic lives, database vs. application&lt;/a&gt;. Referential integrity is the textbook example of a correctness invariant that every write path has to pass through, and the database is the only layer that sees them all.&lt;/p&gt;
&lt;h2 id="the-performance-question"&gt;The performance question
&lt;/h2&gt;&lt;p&gt;If your architecture is so perfectly optimized that a foreign key check is the last thing left to tune, that&amp;rsquo;s not an FK problem. There are almost certainly unindexed queries, missing covering indexes, suboptimal join patterns, N+1 queries, poor partitioning strategy, collation mismatches forcing implicit conversions, functions wrapped around predicates killing index usage, datatype mismatches between join columns, oversized datatypes wasting pages and cache, stale statistics misleading the planner, parameter sniffing locking in bad plans, redundant data bloating tables, under-normalized or over-normalized schemas, tables with too many columns per row, tables with too few columns forcing constant joins, bad query designs pulling more data than needed, misconfigured OS settings, undersized buffer pools, wrong parallelization thresholds, and the list goes on. All hiding somewhere in the stack. If removing an FK constraint is the performance win on the table, it&amp;rsquo;s worth looking harder at everything else first.&lt;/p&gt;
&lt;p&gt;Foreign keys add a check on every insert and update to the child table; the database verifies that the referenced row exists. In practice, this is a lookup against a primary key index. It&amp;rsquo;s fast. Microseconds. The overhead is negligible compared to the cost of tracking down integrity issues after the fact. Teams routinely spend weeks debugging problems that a foreign key would have caught at insert time.&lt;/p&gt;
&lt;p&gt;In practice, the FK check is almost never the bottleneck. This is:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;EXPLAIN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ANALYZE&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;SELECT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;*&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FROM&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;events&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;WHERE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;created_at&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="o"&gt;&amp;gt;&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="s1"&gt;&amp;#39;2024-01-01&amp;#39;&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Seq Scan on events (cost=0.00..4125892.80 rows=198234567 ...)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Planning Time: 0.2 ms
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Execution Time: 287643.109 ms
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;That missing index on a 200 million row table is the bottleneck. Not the FK check.&lt;/p&gt;
&lt;h2 id="its-much-harder-to-add-them-later"&gt;It&amp;rsquo;s much harder to add them later
&lt;/h2&gt;&lt;p&gt;Adding a foreign key to a table with a few thousand rows is trivial. Adding one to a table with hundreds of millions of rows in a production database that&amp;rsquo;s been running for years is a different story entirely.&lt;/p&gt;
&lt;p&gt;The database has to validate every existing row against the constraint. On MySQL, &lt;code&gt;ALTER TABLE&lt;/code&gt; with a foreign key takes a lock; on a large table, that can mean minutes or hours of blocked writes. On PostgreSQL, you can split it into two steps:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Step 1: add the constraint without validating existing rows
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_items&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;ADD&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;CONSTRAINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;fk_order&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;FOREIGN&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;VALID&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Step 2: validate (full table scan, can&amp;#39;t avoid it)
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;ALTER&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_items&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;VALIDATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;CONSTRAINT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;fk_order&lt;/span&gt;&lt;span class="p"&gt;;&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- ERROR: insert or update on table &amp;#34;order_items&amp;#34; violates
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- foreign key constraint &amp;#34;fk_order&amp;#34;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Detail: Key (order_id)=(9912) is not present in table &amp;#34;orders&amp;#34;.
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;div class="warning-box"&gt;
 &lt;strong&gt;The longer you wait, the harder it gets&lt;/strong&gt;
 &lt;div&gt;Adding a foreign key to a table with years of accumulated data means finding and resolving every orphaned row first. On a large, busy database, that cleanup alone can take weeks.&lt;/div&gt;
&lt;/div&gt;

&lt;p&gt;There it is: orphaned data that&amp;rsquo;s been silently accumulating. The constraint can&amp;rsquo;t be added until every violation is found and resolved. On a large, busy database with years of drift, that cleanup alone can take weeks of careful work. Starting with the constraints on day one avoids this entirely.&lt;/p&gt;
&lt;h2 id="what-foreign-keys-actually-give-you"&gt;What foreign keys actually give you
&lt;/h2&gt;&lt;p&gt;Beyond preventing bad data, foreign keys serve as living documentation. They tell every engineer who looks at the schema:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;This table depends on that table&lt;/li&gt;
&lt;li&gt;These rows cannot exist without those rows&lt;/li&gt;
&lt;li&gt;This is the shape of the data, enforced by the system itself&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Documentation gets outdated. Code comments drift. Constraints are always current because the database enforces them on every write.&lt;/p&gt;
&lt;p&gt;Foreign keys also help the query planner. PostgreSQL uses FK relationships to make better decisions about joins. You&amp;rsquo;re protecting your data and helping the database perform better in the same migration.&lt;/p&gt;
&lt;h2 id="fks-as-the-schemas-relationship-map"&gt;FKs as the schema&amp;rsquo;s relationship map
&lt;/h2&gt;&lt;p&gt;The documentation value compounds once the readers aren&amp;rsquo;t all human. &lt;code&gt;information_schema.KEY_COLUMN_USAGE&lt;/code&gt; in MySQL, &lt;code&gt;pg_constraint&lt;/code&gt; in PostgreSQL: foreign keys are queryable catalog metadata, and every schema-reading assistant (Copilot, MCP-backed agents, text-to-SQL tools, RAG systems indexing the catalog) uses that metadata to reason about how tables connect. A declared FK is a machine-readable statement that &lt;code&gt;order_items.order_id&lt;/code&gt; references &lt;code&gt;orders.id&lt;/code&gt;. The model doesn&amp;rsquo;t have to guess from the column name.&lt;/p&gt;
&lt;p&gt;Drop the constraint and the signal disappears. The assistant falls back to guessing joins by name match, which works for obvious cases (&lt;code&gt;user_id&lt;/code&gt; → &lt;code&gt;users.id&lt;/code&gt;) and fails on the real-world column vocabulary every mature schema accumulates: &lt;code&gt;creator_id&lt;/code&gt;, &lt;code&gt;modified_by&lt;/code&gt;, &lt;code&gt;owner&lt;/code&gt;, &lt;code&gt;assigned_to&lt;/code&gt;, &lt;code&gt;ref_id&lt;/code&gt;, &lt;code&gt;parent&lt;/code&gt;. Each of those is a logical FK with no metadata backing it, and a schema-reading model will confidently invent a relationship that doesn&amp;rsquo;t hold. Adding the FK fixes the integrity hole and, in the same migration, makes the schema self-describing to every tool that consumes catalog metadata, including the ones that didn&amp;rsquo;t exist when the table was first created.&lt;/p&gt;
&lt;h2 id="the-nosql-comparison"&gt;The NoSQL comparison
&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s worth noting that document databases like MongoDB don&amp;rsquo;t have this problem in the same way. When an order, its line items, and its shipping address all live inside a single document, there&amp;rsquo;s nothing to orphan - integrity is structural. The data can&amp;rsquo;t reference something that doesn&amp;rsquo;t exist because it&amp;rsquo;s all embedded together.&lt;/p&gt;
&lt;p&gt;That&amp;rsquo;s actually one of the real strengths of the document model. The moment a relational database splits that same data across &lt;code&gt;orders&lt;/code&gt;, &lt;code&gt;line_items&lt;/code&gt;, and &lt;code&gt;addresses&lt;/code&gt; tables, those relationships need to be enforced somewhere. The application can try, but the database is the only place that guarantees it across every write path: manual queries, migrations, ORM edge cases, and all.&lt;/p&gt;
&lt;p&gt;Foreign keys exist because relational databases chose normalization over duplication. That&amp;rsquo;s a good trade-off, but only if the relationships are actually enforced.&lt;/p&gt;
&lt;p&gt;Compare the two models:&lt;/p&gt;
&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt;1
&lt;/span&gt;&lt;span class="lnt"&gt;2
&lt;/span&gt;&lt;span class="lnt"&gt;3
&lt;/span&gt;&lt;span class="lnt"&gt;4
&lt;/span&gt;&lt;span class="lnt"&gt;5
&lt;/span&gt;&lt;span class="lnt"&gt;6
&lt;/span&gt;&lt;span class="lnt"&gt;7
&lt;/span&gt;&lt;span class="lnt"&gt;8
&lt;/span&gt;&lt;span class="lnt"&gt;9
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-js" data-lang="js"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;// Document model - integrity is structural
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;{&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;order_id&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1001&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;user&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;id&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;42&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;name&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Alice&amp;#34;&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="s2"&gt;&amp;#34;items&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;product&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Widget&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;qty&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2&lt;/span&gt; &lt;span class="p"&gt;},&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;{&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;product&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;Gadget&amp;#34;&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="s2"&gt;&amp;#34;qty&amp;#34;&lt;/span&gt;&lt;span class="o"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;1&lt;/span&gt; &lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt; &lt;span class="p"&gt;]&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;}&lt;/span&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;div class="highlight"&gt;&lt;div class="chroma"&gt;
&lt;table class="lntable"&gt;&lt;tr&gt;&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code&gt;&lt;span class="lnt"&gt; 1
&lt;/span&gt;&lt;span class="lnt"&gt; 2
&lt;/span&gt;&lt;span class="lnt"&gt; 3
&lt;/span&gt;&lt;span class="lnt"&gt; 4
&lt;/span&gt;&lt;span class="lnt"&gt; 5
&lt;/span&gt;&lt;span class="lnt"&gt; 6
&lt;/span&gt;&lt;span class="lnt"&gt; 7
&lt;/span&gt;&lt;span class="lnt"&gt; 8
&lt;/span&gt;&lt;span class="lnt"&gt; 9
&lt;/span&gt;&lt;span class="lnt"&gt;10
&lt;/span&gt;&lt;span class="lnt"&gt;11
&lt;/span&gt;&lt;span class="lnt"&gt;12
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;
&lt;td class="lntd"&gt;
&lt;pre tabindex="0" class="chroma"&gt;&lt;code class="language-sql" data-lang="sql"&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="c1"&gt;-- Relational model - integrity must be enforced
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;user_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;users&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="k"&gt;CREATE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;TABLE&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_items&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&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;PRIMARY&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;KEY&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;order_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;orders&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;product_id&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;REFERENCES&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;products&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;id&lt;/span&gt;&lt;span class="p"&gt;),&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="n"&gt;qty&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="nb"&gt;INT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NOT&lt;/span&gt;&lt;span class="w"&gt; &lt;/span&gt;&lt;span class="k"&gt;NULL&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class="line"&gt;&lt;span class="cl"&gt;&lt;span class="p"&gt;);&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;
&lt;/div&gt;
&lt;/div&gt;&lt;p&gt;In the document, there&amp;rsquo;s nothing to orphan. In the relational model, remove those &lt;code&gt;REFERENCES&lt;/code&gt; clauses and every row is on its own.&lt;/p&gt;
&lt;h2 id="when-its-reasonable-to-skip-them"&gt;When it&amp;rsquo;s reasonable to skip them
&lt;/h2&gt;&lt;p&gt;In an OLTP system, almost never. If you&amp;rsquo;re using a relational database, you want relational integrity. That&amp;rsquo;s the whole point. If you don&amp;rsquo;t need enforced relationships between your data, a relational database might not be the right tool in the first place.&lt;/p&gt;
&lt;p&gt;Where foreign keys can be impractical is in specific edge cases:&lt;/p&gt;
&lt;ul&gt;
&lt;li&gt;&lt;strong&gt;Partitioned tables&lt;/strong&gt; where cross-partition foreign keys have historically been unsupported. PostgreSQL 12 added support for foreign keys referencing partitioned tables, though with some limitations: the referenced table must be partitioned, and certain partition schemes can still cause issues.&lt;/li&gt;
&lt;li&gt;&lt;strong&gt;Staging tables&lt;/strong&gt; used for temporary ETL ingestion before data is validated and moved to its final destination.&lt;/li&gt;
&lt;/ul&gt;
&lt;p&gt;Even in analytics and data warehouses, integrity still matters; orphaned or dangling references mean wrong aggregations, broken joins, and reports that silently lie. The enforcement mechanism might look different, but the need for referential integrity doesn&amp;rsquo;t go away just because the workload changed.&lt;/p&gt;
&lt;h2 id="before-you-drop-one"&gt;Before you drop one
&lt;/h2&gt;&lt;p&gt;Before dropping a foreign key for performance, exhaust the thousand other ways to tune your system first. The check itself is a primary-key lookup measured in microseconds; on a profile, it&amp;rsquo;s almost always rounding error compared to the indexes you haven&amp;rsquo;t built, the queries that aren&amp;rsquo;t covering, or the planner stats the FK itself encodes for free. The constraint is almost never the bottleneck, and removing it has a habit of creating new ones a year or two down the line, usually discovered by an engineer who wasn&amp;rsquo;t on the team when the original PR landed.&lt;/p&gt;</description></item></channel></rss>