<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom">
    <channel>
        <title>Ai on EXPLAIN ANALYZE</title>
        <link>https://explainanalyze.com/tags/ai/</link>
        <description>Recent content in Ai on EXPLAIN ANALYZE</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>en-us</language>
        <lastBuildDate>Thu, 23 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://explainanalyze.com/tags/ai/index.xml" rel="self" type="application/rss+xml" /><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>Thu, 23 Apr 2026 00:00:00 +0000</pubDate>
            <guid>https://explainanalyze.com/p/reading-the-schema-is-not-reading-the-data/</guid>
            <description>&lt;div class=&#34;tldr-box&#34;&gt;&#xA;    &lt;strong&gt;TL;DR&lt;/strong&gt;&#xA;    &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. The lever isn&amp;rsquo;t more schema rigor; it&amp;rsquo;s treating the data as a second source that has to be read, sampled, and documented alongside the types.&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;An engineer — or an AI — writes a query to find pending orders:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;SELECT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;total_cents&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;FROM&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;orders&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;status&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;1&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AND&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;&amp;gt;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;NOW&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;()&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;INTERVAL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;7&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;DAY&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&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;&#xA;&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 = &#39;DO_NOT_USE@test.com&#39;&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;&#xA;&lt;h2 id=&#34;four-ways-the-data-disagrees-with-the-schema&#34;&gt;Four ways the data disagrees with the schema&#xA;&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;&#xA;&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=&#34;link&#34; href=&#34;https://explainanalyze.com/p/schema-conventions-dont-survive-without-automation/&#34; &gt;Schema Conventions and Why They Matter&lt;/a&gt; for the prescriptive side; this is the descriptive reality.&lt;/p&gt;&#xA;&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;&#xA;&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;= &#39;2025-01-01&#39;&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;&#xA;&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 = &#39;DO_NOT_USE@test.com&#39;&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 = &#39;1970-01-01 00:00:00&#39;&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;&#xA;&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;&#xA;&lt;h2 id=&#34;why-the-catalog-cant-tell-you-this&#34;&gt;Why the catalog can&amp;rsquo;t tell you this&#xA;&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;&#xA;&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=&#34;link&#34; href=&#34;https://explainanalyze.com/p/null-in-sql-three-valued-logic-and-the-silent-bug-factory/&#34; &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;&#xA;&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=&#34;link&#34; href=&#34;https://explainanalyze.com/p/legacy-schemas-are-sediment-not-design/&#34; &gt;Legacy Schemas Are Sediment&lt;/a&gt; — legacy schemas are one source of data drift; there are others, and they&amp;rsquo;re not all legacy.&lt;/p&gt;&#xA;&lt;div class=&#34;warning-box&#34;&gt;&#xA;    &lt;strong&gt;Runs clean, returns plausible, means something else&lt;/strong&gt;&#xA;    &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;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;h2 id=&#34;the-fix-is-a-habit-not-a-migration&#34;&gt;The fix is a habit, not a migration&#xA;&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;&#xA;&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;&#xA;&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;&#39;Pending=1, Processing=2, Shipped=3, Delivered=4, Cancelled=5&#39;&lt;/code&gt;) and on &lt;code&gt;orders.state&lt;/code&gt; (&lt;code&gt;&#39;Boolean: 1 if order has been through fulfillment&#39;&lt;/code&gt;) is the difference between a reader who gets it right and one who guesses. &lt;a class=&#34;link&#34; href=&#34;https://explainanalyze.com/p/comment-your-schema/&#34; &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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&lt;h2 id=&#34;when-schema-only-reading-is-fine&#34;&gt;When schema-only reading is fine&#xA;&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;&#xA;&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 — but they show up as bugs that get caught, not as the steady-state of the table.&lt;/p&gt;&#xA;&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;&#xA;&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;&#xA;&lt;h2 id=&#34;the-bigger-picture&#34;&gt;The bigger picture&#xA;&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;&#xA;&lt;p&gt;The lever isn&amp;rsquo;t more rigor in the DDL, though rigor on new tables pays off. It&amp;rsquo;s 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 this is glamorous, and none of it scales to &amp;ldquo;we documented the whole schema in one sprint.&amp;rdquo; It scales the way good schema practice always has: 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;&#xA;</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, 23 Apr 2026 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=&#34;https://explainanalyze.com/&#34; alt=&#34;Featured image of post TEXT and JSON Columns: Where the Schema Goes to Hide&#34; /&gt;&lt;div class=&#34;tldr-box&#34;&gt;&#xA;    &lt;strong&gt;TL;DR&lt;/strong&gt;&#xA;    &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. Readers can&amp;rsquo;t query into it without knowing the format, planners can&amp;rsquo;t reason about it, and the shape drifts across years of writes with no signal to the next reader. The fix isn&amp;rsquo;t &amp;ldquo;don&amp;rsquo;t use JSON&amp;rdquo;; it&amp;rsquo;s to promote the fields that actually get queried into real columns and treat the rest as genuinely opaque.&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&#xA;&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;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;5&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;SELECT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;JSON_EXTRACT&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;payload&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;$.action&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;action&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;FROM&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;api_logs&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;JSON_EXTRACT&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;payload&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;$.action&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;upgrade&amp;#39;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AND&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;JSON_EXTRACT&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;payload&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;$.plan&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;   &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;enterprise&amp;#39;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AND&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;&amp;gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;NOW&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;()&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;-&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;INTERVAL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;90&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;DAY&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&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;&#xA;&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 — it&amp;rsquo;s that the schema emigrated to application code, and changing the column type doesn&amp;rsquo;t bring it back.&lt;/p&gt;&#xA;&lt;h2 id=&#34;what-leaves-the-catalog-when-the-column-becomes-a-blob&#34;&gt;What leaves the catalog when the column becomes a blob&#xA;&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;&#xA;&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 = &#39;json&#39;&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=&#34;link&#34; href=&#34;https://explainanalyze.com/p/comment-your-schema/&#34; &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;&#xA;&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=&#34;link&#34; href=&#34;https://explainanalyze.com/p/legacy-schemas-are-sediment-not-design/&#34; &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;&#xA;&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;&#xA;&lt;p&gt;&lt;strong&gt;The planner is working blind.&lt;/strong&gt; Row-count estimates on &lt;code&gt;JSON_EXTRACT(payload, &#39;$.event.type&#39;) = &#39;upgrade&#39;&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;&#xA;&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, &#39;$.event.type&#39;)&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, &#39;$.event.type&#39;)) 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;&#xA;&lt;div class=&#34;warning-box&#34;&gt;&#xA;    &lt;strong&gt;Untyped writes &amp;#43; untyped reads = silent schema drift&lt;/strong&gt;&#xA;    &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 just 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;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;h2 id=&#34;plausible-paths-empty-results&#34;&gt;Plausible paths, empty results&#xA;&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 = &#39;upgrade&#39;&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;&#xA;&lt;p&gt;The failure pattern is familiar from other schema-hiding designs. &lt;a class=&#34;link&#34; href=&#34;https://explainanalyze.com/p/polymorphic-references-are-not-foreign-keys/&#34; &gt;Polymorphic references&lt;/a&gt; hide which table a foreign-key-shaped column points at; &lt;a class=&#34;link&#34; href=&#34;https://explainanalyze.com/p/the-bare-id-primary-key-when-every-table-joins-to-every-other-table/&#34; &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;&#xA;&lt;h2 id=&#34;the-fix-and-where-it-stops-being-free&#34;&gt;The fix, and where it stops being free&#xA;&lt;/h2&gt;&lt;p&gt;The lever isn&amp;rsquo;t &amp;ldquo;avoid JSON&amp;rdquo; — which is both impractical and sometimes wrong — it&amp;rsquo;s to be honest about what&amp;rsquo;s inside and pick the right storage per field.&lt;/p&gt;&#xA;&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;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;ALTER&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;TABLE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;api_logs&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;ADD&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;COLUMN&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;event_type&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;VARCHAR&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;50&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;GENERATED&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;ALWAYS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;JSON_UNQUOTE&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;JSON_EXTRACT&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;payload&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;$.event.type&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)))&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;STORED&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;ADD&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;INDEX&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;idx_event_type&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;event_type&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&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, &#39;$.event.type&#39;), JSON_EXTRACT(payload, &#39;$.action&#39;))&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;&#xA;&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;&#xA;&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;&#xA;&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=&#34;link&#34; href=&#34;https://explainanalyze.com/p/comment-your-schema/&#34; &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;&#xA;&lt;h2 id=&#34;when-json-is-actually-the-right-answer&#34;&gt;When JSON is actually the right answer&#xA;&lt;/h2&gt;&lt;p&gt;The pattern earns its keep in specific shapes where the alternative — typed columns — is worse.&lt;/p&gt;&#xA;&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=&#34;link&#34; href=&#34;https://explainanalyze.com/p/god-tables-150-columns-and-the-quiet-cost-of-just-add-a-column/&#34; &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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&lt;h2 id=&#34;the-bigger-picture&#34;&gt;The bigger picture&#xA;&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;&#xA;&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, and the cost is paid in the form of queries that return plausible answers to questions the data can&amp;rsquo;t actually answer.&lt;/p&gt;&#xA;</description>
        </item><item>
            <title>God Tables: 150 Columns and the Quiet Cost of &#39;Just Add a Column&#39;</title>
            <link>https://explainanalyze.com/p/god-tables-150-columns-and-the-quiet-cost-of-just-add-a-column/</link>
            <pubDate>Wed, 22 Apr 2026 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=&#34;https://explainanalyze.com/&#34; alt=&#34;Featured image of post God Tables: 150 Columns and the Quiet Cost of &#39;Just Add a Column&#39;&#34; /&gt;&lt;div class=&#34;tldr-box&#34;&gt;&#xA;    &lt;strong&gt;TL;DR&lt;/strong&gt;&#xA;    &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 isn&amp;rsquo;t aggressive normalization (which trades one wide table for six-way joins on every read) but splitting by access pattern: columns read together stay together, rarely-touched columns move out.&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&#xA;&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;&#xA;&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;&#xA;&lt;h2 id=&#34;how-a-row-store-actually-reads-a-row&#34;&gt;How a row-store actually reads a row&#xA;&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;&#xA;&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=&#34;link&#34; href=&#34;https://explainanalyze.com/p/covering-index-traps-when-adding-one-column-breaks-your-query/&#34; &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;&#xA;&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;&#xA;&lt;h2 id=&#34;what-150-columns-actually-costs&#34;&gt;What 150 columns actually costs&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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=&#34;link&#34; href=&#34;https://explainanalyze.com/p/covering-index-traps-when-adding-one-column-breaks-your-query/&#34; &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;&#xA;&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;&#xA;&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. But 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;&#xA;&lt;div class=&#34;warning-box&#34;&gt;&#xA;    &lt;strong&gt;Every column is a commitment&lt;/strong&gt;&#xA;    &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;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;h2 id=&#34;why-llms-make-this-worse&#34;&gt;Why LLMs make this worse&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&lt;h2 id=&#34;split-by-access-pattern-not-by-concept&#34;&gt;Split by access pattern, not by concept&#xA;&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;&#xA;&lt;p&gt;A workable decomposition for the &lt;code&gt;customers&lt;/code&gt; example:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&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;&#xA;&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;&#xA;&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=&#34;link&#34; href=&#34;https://explainanalyze.com/p/polymorphic-references-are-not-foreign-keys/&#34; &gt;Polymorphic References&lt;/a&gt; for the related pattern where doing this without a FK goes wrong.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&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;&#xA;&lt;h2 id=&#34;when-a-wide-table-is-actually-fine&#34;&gt;When a wide table is actually fine&#xA;&lt;/h2&gt;&lt;p&gt;Not every 100-column table is a god table. Three cases where width is defensible:&lt;/p&gt;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&lt;h2 id=&#34;the-bigger-picture&#34;&gt;The bigger picture&#xA;&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. The SELECT-*-and-done dream is the developer&amp;rsquo;s cost model, not the database&amp;rsquo;s.&lt;/p&gt;&#xA;&lt;p&gt;God tables aren&amp;rsquo;t designed; they&amp;rsquo;re 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;&#xA;&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. When it&amp;rsquo;s no, the split is far cheaper at column sixty than at column one-eighty; the table doesn&amp;rsquo;t care, but every caller of the table does, and the rewrite&amp;rsquo;s blast radius scales with how long the drift went uncorrected.&lt;/p&gt;&#xA;</description>
        </item><item>
            <title>Legacy Schemas Are Sediment, Not Design</title>
            <link>https://explainanalyze.com/p/legacy-schemas-are-sediment-not-design/</link>
            <pubDate>Wed, 22 Apr 2026 00:00:00 +0000</pubDate>
            <guid>https://explainanalyze.com/p/legacy-schemas-are-sediment-not-design/</guid>
            <description>&lt;img src=&#34;https://explainanalyze.com/&#34; alt=&#34;Featured image of post Legacy Schemas Are Sediment, Not Design&#34; /&gt;&lt;div class=&#34;tldr-box&#34;&gt;&#xA;    &lt;strong&gt;TL;DR&lt;/strong&gt;&#xA;    &lt;div&gt;A legacy schema looks like a design but 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. The fix isn&amp;rsquo;t renaming (prohibitively expensive once every caller depends on the current names); it&amp;rsquo;s documenting the drift so the next reader — human or LLM — can navigate what&amp;rsquo;s actually there.&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&#xA;&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;&#xA;&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;&#xA;&lt;h2 id=&#34;whats-drifted&#34;&gt;What&amp;rsquo;s drifted&#xA;&lt;/h2&gt;&lt;p&gt;Legacy drift shows up in three visible modes and one invisible one.&lt;/p&gt;&#xA;&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=&#34;link&#34; href=&#34;https://explainanalyze.com/p/comment-your-schema/&#34; &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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&lt;h2 id=&#34;why-this-is-worse-for-llms-than-for-humans&#34;&gt;Why this is worse for LLMs than for humans&#xA;&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;&#xA;&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; and the schema gives no signal that this is one of the cases where reasoning from the name produces bad SQL.&lt;/p&gt;&#xA;&lt;p&gt;This is the sharper version of the &lt;a class=&#34;link&#34; href=&#34;https://explainanalyze.com/p/the-bare-id-primary-key-when-every-table-joins-to-every-other-table/&#34; &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 &lt;em&gt;means&lt;/em&gt;. 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;&#xA;&lt;h2 id=&#34;the-fix-is-documentation-not-renaming&#34;&gt;The fix is documentation, not renaming&#xA;&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;&#xA;&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=&#34;link&#34; href=&#34;https://explainanalyze.com/p/schema-conventions-dont-survive-without-automation/&#34; &gt;Schema Conventions and Why They Matter&lt;/a&gt; covers the mechanics). Making existing drift visible means &lt;a class=&#34;link&#34; href=&#34;https://explainanalyze.com/p/comment-your-schema/&#34; &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;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;5&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;COMMENT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;ON&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;TABLE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;tmp_orders&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;IS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;s1&#34;&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=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;COMMENT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;ON&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;COLUMN&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;customers&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;flag1&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;IS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;s1&#34;&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=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&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;&#xA;&lt;h2 id=&#34;when-a-clean-rewrite-is-actually-worth-it&#34;&gt;When a clean rewrite is actually worth it&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&lt;h2 id=&#34;the-bigger-picture&#34;&gt;The bigger picture&#xA;&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 fix isn&amp;rsquo;t to aspire to a clean schema that doesn&amp;rsquo;t accumulate history — no such schema exists past a three-year horizon — but to leave the next reader enough signal to decompress the sediment without guessing.&lt;/p&gt;&#xA;&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. The goal isn&amp;rsquo;t a schema without legacy drift; it&amp;rsquo;s a schema whose drift is legible to the people and tools that will inherit it.&lt;/p&gt;&#xA;</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>Wed, 22 Apr 2026 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=&#34;https://explainanalyze.com/&#34; alt=&#34;Featured image of post The Bare `id` Primary Key: When Every Table Joins to Every Other Table&#34; /&gt;&lt;div class=&#34;tldr-box&#34;&gt;&#xA;    &lt;strong&gt;TL;DR&lt;/strong&gt;&#xA;    &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. The fix isn&amp;rsquo;t picking the &amp;ldquo;right&amp;rdquo; PK type — it&amp;rsquo;s naming primary keys after the table they identify, so the schema describes its own relationships.&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;Here&amp;rsquo;s a query an AI assistant generated against a real production schema:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;SELECT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;u&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;email&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;a&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;payload&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;FROM&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;users&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;u&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;JOIN&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;actions&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;a&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;ON&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;u&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;a&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;u&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;email&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;alice@example.com&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&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 was that &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;&#xA;&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;&#xA;&lt;h2 id=&#34;what-nobody-can-see&#34;&gt;What nobody can see&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&lt;h2 id=&#34;three-failure-modes-ranked-by-how-loudly-they-fail&#34;&gt;Three failure modes, ranked by how loudly they fail&#xA;&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;&#xA;&lt;ul&gt;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;The first two are loud enough to catch in review. The third is the one that ships. And 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;&#xA;&lt;div class=&#34;warning-box&#34;&gt;&#xA;    &lt;strong&gt;Zero rows looks like no data&lt;/strong&gt;&#xA;    &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;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;h2 id=&#34;mixed-pk-types-make-the-naming-problem-sharper&#34;&gt;Mixed PK types make the naming problem sharper&#xA;&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;&#xA;&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;&#xA;&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=&#34;link&#34; href=&#34;https://explainanalyze.com/p/random-uuids-as-primary-keys-the-b-tree-penalty/&#34; &gt;Random UUIDs as Primary Keys&lt;/a&gt;. The two problems interact but have independent fixes: pick your PK types deliberately, &lt;em&gt;and&lt;/em&gt; name them so the schema describes its own relationships. Neither fix substitutes for the other.&lt;/p&gt;&#xA;&lt;h2 id=&#34;naming-is-the-lever-that-actually-helps&#34;&gt;Naming is the lever that actually helps&#xA;&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;&#xA;&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=&#34;link&#34; href=&#34;https://explainanalyze.com/p/schema-conventions-dont-survive-without-automation/&#34; &gt;Schema Conventions and Why They Matter&lt;/a&gt;.&lt;/p&gt;&#xA;&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=&#34;link&#34; href=&#34;https://explainanalyze.com/p/foreign-keys-are-not-optional/&#34; &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;&#xA;&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;&#xA;&lt;h2 id=&#34;when-bare-id-is-actually-fine&#34;&gt;When bare &lt;code&gt;id&lt;/code&gt; is actually fine&#xA;&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;&#xA;&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;&#xA;&lt;h2 id=&#34;the-bigger-picture&#34;&gt;The bigger picture&#xA;&lt;/h2&gt;&lt;p&gt;A schema&amp;rsquo;s job isn&amp;rsquo;t just to hold data correctly; it&amp;rsquo;s to 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;&#xA;&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, which is where most of us end up.&lt;/p&gt;&#xA;</description>
        </item><item>
            <title>Polymorphic References Are Not Foreign Keys</title>
            <link>https://explainanalyze.com/p/polymorphic-references-are-not-foreign-keys/</link>
            <pubDate>Tue, 21 Apr 2026 00:00:00 +0000</pubDate>
            <guid>https://explainanalyze.com/p/polymorphic-references-are-not-foreign-keys/</guid>
            <description>&lt;img src=&#34;https://explainanalyze.com/&#34; alt=&#34;Featured image of post Polymorphic References Are Not Foreign Keys&#34; /&gt;&lt;div class=&#34;tldr-box&#34;&gt;&#xA;    &lt;strong&gt;TL;DR&lt;/strong&gt;&#xA;    &lt;div&gt;A polymorphic reference is a pair of columns — &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. One column of data, many possible targets. ORMs make it a one-liner (&lt;code&gt;polymorphic: true&lt;/code&gt; in Rails, &lt;code&gt;GenericForeignKey&lt;/code&gt; in Django, &lt;code&gt;morphTo&lt;/code&gt; in Laravel) and the database can&amp;rsquo;t help with any of it: no foreign key, no cascade, no planner metadata, no schema-level description of what the column actually references. Reads need conditional joins or unions. Orphans accumulate silently. For the cases people usually reach for it — comments, notifications, attachments — the alternatives (per-target tables, mutually-exclusive nullable FKs with a &lt;code&gt;CHECK&lt;/code&gt;) restore schema integrity at modest cost. The pattern earns its keep only where the relationship is genuinely best-effort, like audit or activity logs.&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;h2 id=&#34;what-the-pattern-looks-like&#34;&gt;What the pattern looks like&#xA;&lt;/h2&gt;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt; 1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 4&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 5&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 6&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 7&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 8&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 9&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;10&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;11&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;12&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;CREATE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;TABLE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;notifications&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;PRIMARY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;KEY&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;user_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;REFERENCES&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;users&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;),&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;resource_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;resource_type&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;VARCHAR&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;50&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;message&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;TEXT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;created_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;DEFAULT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;NOW&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;()&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- resource_type = &amp;#39;order&amp;#39;   → resource_id references orders.id&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- resource_type = &amp;#39;invoice&amp;#39; → resource_id references invoices.id&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- resource_type = &amp;#39;ticket&amp;#39;  → resource_id references support_tickets.id&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&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;&#xA;&lt;h2 id=&#34;what-the-database-cant-do&#34;&gt;What the database can&amp;rsquo;t do&#xA;&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;&#xA;&lt;ul&gt;&#xA;&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=&#34;link&#34; href=&#34;https://explainanalyze.com/p/foreign-keys-are-not-optional/&#34; &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;&#xA;&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;&#xA;&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;&#xA;&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=&#34;link&#34; href=&#34;https://explainanalyze.com/p/comment-your-schema/&#34; &gt;Comment Your Schema&lt;/a&gt; helps here but can&amp;rsquo;t fully restore the information.)&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;div class=&#34;warning-box&#34;&gt;&#xA;    &lt;strong&gt;Orphans accumulate silently&lt;/strong&gt;&#xA;    &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;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;h2 id=&#34;reads-pay-for-the-write-side-convenience&#34;&gt;Reads pay for the write-side convenience&#xA;&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;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;5&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;6&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;7&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;8&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;9&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- Conditional LEFT JOIN per target&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;SELECT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;n&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;n&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;message&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;       &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;COALESCE&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;o&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;order_number&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;i&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;invoice_number&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;t&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;ticket_code&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;ref&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;FROM&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;notifications&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;n&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;LEFT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;JOIN&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;orders&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;   &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;o&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;ON&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;n&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;resource_type&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;order&amp;#39;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;   &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AND&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;n&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;resource_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;o&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;LEFT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;JOIN&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;invoices&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;i&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;ON&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;n&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;resource_type&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;invoice&amp;#39;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AND&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;n&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;resource_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;i&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;LEFT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;JOIN&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;support_tickets&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;t&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;                     &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;ON&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;n&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;resource_type&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;ticket&amp;#39;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AND&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;n&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;resource_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;t&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;n&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;user_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;42&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&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. And 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;&#xA;&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;&#xA;&lt;h2 id=&#34;why-the-pattern-spreads&#34;&gt;Why the pattern spreads&#xA;&lt;/h2&gt;&lt;p&gt;It&amp;rsquo;s not a designed-in choice; 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;&#xA;&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=&#34;link&#34; href=&#34;https://explainanalyze.com/p/orms-are-a-coupling-not-an-abstraction/&#34; &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;&#xA;&lt;h2 id=&#34;alternatives&#34;&gt;Alternatives&#xA;&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;&#xA;&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 — but that union already exists implicitly in the polymorphic shape, just moved from the read query into typed branches.&lt;/p&gt;&#xA;&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;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt; 1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 4&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 5&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 6&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 7&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 8&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 9&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;10&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;11&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;12&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;13&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;CREATE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;TABLE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;notifications&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;PRIMARY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;KEY&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;user_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;REFERENCES&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;users&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;),&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;order_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;REFERENCES&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;orders&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;),&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;invoice_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;REFERENCES&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;invoices&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;),&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;ticket_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;REFERENCES&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;support_tickets&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;),&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;message&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;TEXT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;CONSTRAINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;exactly_one_target&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;CHECK&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;order_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;IS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)::&lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;int&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;+&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;invoice_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;IS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)::&lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;int&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;+&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;        &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;ticket_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;IS&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)::&lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;int&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;1&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&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;&#xA;&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;&#xA;&lt;h2 id=&#34;when-polymorphic-is-actually-the-right-call&#34;&gt;When polymorphic is actually the right call&#xA;&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;&#xA;&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;&#xA;&lt;h2 id=&#34;the-bigger-picture&#34;&gt;The bigger picture&#xA;&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;&#xA;&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, not by default — and 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;&#xA;</description>
        </item></channel>
</rss>
