<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel><title>Schema-Design on EXPLAIN ANALYZE</title><link>https://explainanalyze.com/categories/schema-design/</link><description>Recent content in Schema-Design on EXPLAIN ANALYZE</description><generator>Hugo -- gohugo.io</generator><language>en-us</language><lastBuildDate>Thu, 25 Sep 2025 00:00:00 +0000</lastBuildDate><atom:link href="https://explainanalyze.com/categories/schema-design/index.xml" rel="self" type="application/rss+xml"/><item><title>TEXT and JSON Columns: Where the Schema Goes to Hide</title><link>https://explainanalyze.com/p/text-and-json-columns-where-the-schema-goes-to-hide/</link><pubDate>Thu, 25 Sep 2025 00:00:00 +0000</pubDate><guid>https://explainanalyze.com/p/text-and-json-columns-where-the-schema-goes-to-hide/</guid><description>&lt;img src="https://explainanalyze.com/" alt="Featured image of post TEXT and JSON Columns: Where the Schema Goes to Hide" /&gt;&lt;div class="tldr-box"&gt;
 &lt;strong&gt;TL;DR&lt;/strong&gt;
 &lt;div&gt;A &lt;code&gt;TEXT&lt;/code&gt; or &lt;code&gt;JSON&lt;/code&gt; column moves the schema out of the database catalog and into application code; the data inside has a shape, but the DDL won&amp;rsquo;t tell you what it is. Promote the fields that actually get queried into real columns, and treat the rest as genuinely opaque.&lt;/div&gt;
&lt;/div&gt;

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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