<?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/tags/schema-design/</link>
        <description>Recent content in Schema-Design on EXPLAIN ANALYZE</description>
        <generator>Hugo -- gohugo.io</generator>
        <language>en-us</language>
        <lastBuildDate>Mon, 20 Apr 2026 00:00:00 +0000</lastBuildDate><atom:link href="https://explainanalyze.com/tags/schema-design/index.xml" rel="self" type="application/rss+xml" /><item>
            <title>ORMs Are a Coupling, Not an Abstraction</title>
            <link>https://explainanalyze.com/p/orms-are-a-coupling-not-an-abstraction/</link>
            <pubDate>Mon, 20 Apr 2026 00:00:00 +0000</pubDate>
            <guid>https://explainanalyze.com/p/orms-are-a-coupling-not-an-abstraction/</guid>
            <description>&lt;img src=&#34;https://explainanalyze.com/&#34; alt=&#34;Featured image of post ORMs Are a Coupling, Not an Abstraction&#34; /&gt;&lt;div class=&#34;tldr-box&#34;&gt;&#xA;    &lt;strong&gt;TL;DR&lt;/strong&gt;&#xA;    &lt;div&gt;An ORM mirrors your schema shape onto code shape — columns become fields, tables become classes, relations become methods. That mirror is the product, and also the coupling. Every schema change becomes a multi-file code change. The data model ends up scattered across the schema, migration history, model classes, serializers, and test fixtures, and those views drift. Migrating off an ORM is harder than changing a database, because the ORM&amp;rsquo;s conventions bleed into controllers, APIs, and third-party integrations. The cases where the velocity payoff justifies the coupling are narrow — short-lived prototypes, internal CRUD tools, workloads whose shape fits the ORM&amp;rsquo;s defaults and won&amp;rsquo;t outgrow them. Any application that outlives a quarter, grows past a million rows, or needs non-trivial query shapes is better served by a thinner abstraction over raw SQL — sqlc, jOOQ, typed query builders — that keeps the schema as the single source of truth. SQL is the most universally-deployed programming language in the industry; hiding it behind a framework-specific mirror is a choice worth justifying, not a default worth inheriting.&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&#xA;&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 replace Rails 4 with Rails 7 — or skip straight to something else entirely.&lt;/p&gt;&#xA;&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. And the contract shapes more than how changes propagate: it 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. This isn&amp;rsquo;t a claim that ORMs have no place — short-lived prototypes and simple CRUD apps benefit from them. It is a claim that the defensible use cases are narrower than the industry&amp;rsquo;s default deployment pattern suggests, and that the coupling they introduce is real, durable, and consistently underestimated at the point a team decides to adopt one.&lt;/p&gt;&#xA;&lt;div class=&#34;note-box&#34;&gt;&#xA;    &lt;strong&gt;The oddity worth pausing on&lt;/strong&gt;&#xA;    &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;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;p&gt;The rest of this post offers one answer for why that&amp;rsquo;s become 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;&#xA;&lt;h2 id=&#34;what-the-orm-is-actually-doing&#34;&gt;What the ORM is actually doing&#xA;&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 &lt;em&gt;is&lt;/em&gt; 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;&#xA;&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;&#xA;&lt;p&gt;Stay in sync, in practice, means &amp;ldquo;every schema change is also a code change.&amp;rdquo; 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;&#xA;&lt;h2 id=&#34;source-of-truth-pick-one-know-which&#34;&gt;Source of truth: pick one, know which&#xA;&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;&#xA;&lt;ul&gt;&#xA;&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;&#xA;&lt;li&gt;&lt;strong&gt;Schema-first.&lt;/strong&gt; &lt;a class=&#34;link&#34; href=&#34;https://sqlc.dev/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;sqlc&lt;/a&gt; and &lt;a class=&#34;link&#34; href=&#34;https://www.jooq.org/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &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;&#xA;&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;&#xA;&lt;/ul&gt;&#xA;&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 that got 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;&#xA;&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;&#xA;&lt;h2 id=&#34;migrations-stop-being-db-work&#34;&gt;Migrations stop being &amp;ldquo;DB work&amp;rdquo;&#xA;&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;&#xA;&lt;p&gt;In an ORM codebase, a single logical schema change is typically:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;A migration file (&lt;code&gt;add_email_to_users.rb&lt;/code&gt;).&lt;/li&gt;&#xA;&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;&#xA;&lt;li&gt;The serializer (&lt;code&gt;UserSerializer#email&lt;/code&gt;).&lt;/li&gt;&#xA;&lt;li&gt;The API contract (OpenAPI spec, GraphQL schema, whatever the team uses).&lt;/li&gt;&#xA;&lt;li&gt;Fixtures and factories (FactoryBot, factory_boy, test data).&lt;/li&gt;&#xA;&lt;li&gt;Query helpers that need to know the new column.&lt;/li&gt;&#xA;&lt;li&gt;Type stubs or generated types (TypeScript declarations, Python stubs).&lt;/li&gt;&#xA;&lt;li&gt;Admin UI config, sometimes.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&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;&#xA;&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;&#xA;&lt;h2 id=&#34;hidden-queries&#34;&gt;Hidden queries&#xA;&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;&#xA;&lt;ul&gt;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&lt;/ul&gt;&#xA;&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 that nobody intentionally wrote.&lt;/p&gt;&#xA;&lt;div class=&#34;warning-box&#34;&gt;&#xA;    &lt;strong&gt;The queries you don&amp;#39;t see&lt;/strong&gt;&#xA;    &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 that rely 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;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;h2 id=&#34;two-query-languages-neither-complete&#34;&gt;Two query languages, neither complete&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&lt;h2 id=&#34;bidirectional-coupling&#34;&gt;Bidirectional coupling&#xA;&lt;/h2&gt;&lt;p&gt;The part that surprises teams is how hard it is to leave.&lt;/p&gt;&#xA;&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;&#xA;&lt;p&gt;Migrating &lt;em&gt;off&lt;/em&gt; an ORM is not mechanical. The ORM&amp;rsquo;s conventions have bled into:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&lt;li&gt;&lt;strong&gt;Query helpers.&lt;/strong&gt; Every scope, every association, every callback is ORM-native.&lt;/li&gt;&#xA;&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;&#xA;&lt;/ul&gt;&#xA;&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;&#xA;&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;&#xA;&lt;h2 id=&#34;database-side-logic-doesnt-round-trip&#34;&gt;Database-side logic doesn&amp;rsquo;t round-trip&#xA;&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;&#xA;&lt;ul&gt;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&lt;/ul&gt;&#xA;&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;&#xA;&lt;h2 id=&#34;relational-modeling-isnt-object-modeling&#34;&gt;Relational modeling isn&amp;rsquo;t object modeling&#xA;&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;&#xA;&lt;p&gt;This mismatch has a name: the &lt;strong&gt;object-relational impedance mismatch&lt;/strong&gt;. 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;&#xA;&lt;p&gt;&lt;strong&gt;Normalization doesn&amp;rsquo;t look like inheritance.&lt;/strong&gt; 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 — but each has different additional fields.&lt;/p&gt;&#xA;&lt;p&gt;The relational answer is a &lt;strong&gt;supertype/subtype&lt;/strong&gt; 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;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt; 1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 4&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 5&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 6&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 7&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 8&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt; 9&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;10&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;11&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;12&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;13&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;14&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;15&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;16&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;17&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;18&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;19&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;20&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;21&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;22&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;23&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;24&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;25&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;26&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;CREATE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;TABLE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;events&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;PRIMARY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;KEY&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;user_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;REFERENCES&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;users&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;),&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;starts_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;ends_at&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;TIMESTAMPTZ&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;kind&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;TEXT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;CHECK&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;kind&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;IN&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;appointment&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;day_off&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;launch&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;))&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;CREATE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;TABLE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;appointments&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;event_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;PRIMARY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;KEY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;REFERENCES&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;events&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;ON&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;DELETE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;CASCADE&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;client_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;REFERENCES&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;clients&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;),&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;location&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;TEXT&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;notes&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;TEXT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;CREATE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;TABLE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;days_off&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;event_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;PRIMARY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;KEY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;REFERENCES&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;events&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;ON&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;DELETE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;CASCADE&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;reason&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;TEXT&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;paid&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BOOLEAN&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;CREATE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;TABLE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;launches&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;event_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;PRIMARY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;KEY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;REFERENCES&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;events&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;ON&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;DELETE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;CASCADE&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;product_id&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;BIGINT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NOT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;NULL&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;REFERENCES&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;products&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;id&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;),&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;    &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;audience&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;nb&#34;&gt;TEXT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;p&#34;&gt;);&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;Each subtype has its own columns, its own indexes, its own 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;&#xA;&lt;p&gt;The ORM-driven shape tends to produce something different. Rails&amp;rsquo; &lt;strong&gt;single-table inheritance (STI)&lt;/strong&gt; 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;&#xA;&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;&#xA;&lt;p&gt;&lt;strong&gt;Complex relationships don&amp;rsquo;t fit class graphs.&lt;/strong&gt; 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;&#xA;&lt;p&gt;&lt;strong&gt;Normalization decisions are driven by access patterns, not classes.&lt;/strong&gt; 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;&#xA;&lt;p&gt;The coupling here isn&amp;rsquo;t just 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;&#xA;&lt;h2 id=&#34;when-scale-exposes-the-modeling&#34;&gt;When scale exposes the modeling&#xA;&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;&#xA;&lt;ul&gt;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&lt;/ul&gt;&#xA;&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. &lt;strong&gt;Materialized views&lt;/strong&gt; are the common one — 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. &lt;strong&gt;Read replicas with application-level routing&lt;/strong&gt; 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. &lt;strong&gt;Caching layers&lt;/strong&gt; 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;&#xA;&lt;div class=&#34;warning-box&#34;&gt;&#xA;    &lt;strong&gt;Materialized views aren&amp;#39;t an OLTP tool&lt;/strong&gt;&#xA;    &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;&#xA;&lt;/div&gt;&#xA;&#xA;&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;&#xA;&lt;h2 id=&#34;the-thinner-alternatives&#34;&gt;The thinner alternatives&#xA;&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 introducing the mapping layer.&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;&lt;a class=&#34;link&#34; href=&#34;https://sqlc.dev/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &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;&#xA;&lt;li&gt;&lt;strong&gt;&lt;a class=&#34;link&#34; href=&#34;https://www.jooq.org/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &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;&#xA;&lt;li&gt;&lt;strong&gt;&lt;a class=&#34;link&#34; href=&#34;https://kysely.dev/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &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;&#xA;&lt;li&gt;&lt;strong&gt;&lt;a class=&#34;link&#34; href=&#34;https://orm.drizzle.team/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &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;&#xA;&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;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;The common thread across these tools: &lt;strong&gt;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.&lt;/strong&gt; 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;&#xA;&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;&#xA;&lt;h2 id=&#34;where-orms-still-earn-their-place&#34;&gt;Where ORMs still earn their place&#xA;&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;&#xA;&lt;ul&gt;&#xA;&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;&#xA;&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;&#xA;&lt;/ul&gt;&#xA;&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;&#xA;&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 that engineers end up cursing started in one of the two bullets above and were never reconsidered when they outgrew them.&lt;/p&gt;&#xA;&lt;h2 id=&#34;trade-offs&#34;&gt;Trade-offs&#xA;&lt;/h2&gt;&lt;p&gt;Everything in this post has a counter-argument, and the counter-arguments are real.&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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; isn&amp;rsquo;t &amp;ldquo;no abstraction&amp;rdquo; — it&amp;rsquo;s &amp;ldquo;a different abstraction, maintained by you.&amp;rdquo;&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;The honest framing: the choice isn&amp;rsquo;t ideological. It&amp;rsquo;s a trade between two failure modes — the ORM&amp;rsquo;s coupling cost vs. 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 that survive 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;&#xA;&lt;h2 id=&#34;the-bigger-picture&#34;&gt;The bigger picture&#xA;&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 — that 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;&#xA;&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. The schema is the part of the system that ages well; the ORM is the part that doesn&amp;rsquo;t. 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;&#xA;&lt;p&gt;That&amp;rsquo;s the inversion the industry hasn&amp;rsquo;t made. The default is still &amp;ldquo;reach for the ORM first, consider alternatives when it hurts.&amp;rdquo; The paradox from the opening is what makes that default hard to justify. SQL is the single most universally-deployed programming language in the industry — stable for decades, portable across engines, understood by every backend engineer who&amp;rsquo;s been around more than a few years. The ORM hiding it is the opposite of all those things. Defaulting to the less durable tool means paying a coupling cost indefinitely in exchange for a velocity payoff that mostly runs out in the first year or two.&lt;/p&gt;&#xA;&lt;p&gt;If you&amp;rsquo;re starting a project expected to live more than a year, default to schema-first. Reach for the ORM only when the project&amp;rsquo;s scope and lifespan are genuinely narrow. If you&amp;rsquo;re inside an existing ORM codebase and 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 question isn&amp;rsquo;t &amp;ldquo;should we migrate off.&amp;rdquo; It&amp;rsquo;s &amp;ldquo;where do we start drawing the schema-first boundary for new work?&amp;rdquo; 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;&#xA;&lt;p&gt;The schema outlives the ORM. Design for that.&lt;/p&gt;&#xA;</description>
        </item><item>
            <title>Schema Conventions Don&#39;t Survive Without Automation</title>
            <link>https://explainanalyze.com/p/schema-conventions-dont-survive-without-automation/</link>
            <pubDate>Mon, 20 Apr 2026 00:00:00 +0000</pubDate>
            <guid>https://explainanalyze.com/p/schema-conventions-dont-survive-without-automation/</guid>
            <description>&lt;img src=&#34;https://explainanalyze.com/&#34; alt=&#34;Featured image of post Schema Conventions Don&#39;t Survive Without Automation&#34; /&gt;&lt;div class=&#34;tldr-box&#34;&gt;&#xA;    &lt;strong&gt;TL;DR&lt;/strong&gt;&#xA;    &lt;div&gt;Conventions only survive when automation depends on them. A rule a linter, ORM, migration runner, or IaC module enforces will hold for years because the tool fails the build when someone violates it. A rule that only exists because someone preferred it — &lt;code&gt;snake_case&lt;/code&gt; felt cleaner, plural reads better, &lt;code&gt;id&lt;/code&gt; or &lt;code&gt;&amp;lt;table&amp;gt;_id&lt;/code&gt; as a matter of taste — won&amp;rsquo;t outlast the people who picked it. New engineers join with their own preferences, the team that agreed moves on, and within a few quarters the schema is a mix of two or three &amp;ldquo;once preferred&amp;rdquo; patterns with no one left to defend any of them. Pick the conventions your automation enforces. Skip the purely subjective ones — there&amp;rsquo;s no middle ground that survives.&lt;/div&gt;&#xA;&lt;/div&gt;&#xA;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&lt;h2 id=&#34;what-conventions-means-here&#34;&gt;What &amp;ldquo;conventions&amp;rdquo; means here&#xA;&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;&#xA;&lt;ul&gt;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&lt;/ul&gt;&#xA;&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;&#xA;&lt;h2 id=&#34;humans-benefit--but-not-durably&#34;&gt;Humans benefit — but not durably&#xA;&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;&#xA;&lt;p&gt;&lt;strong&gt;Onboarding.&lt;/strong&gt; A new engineer spends less time building a mental model when PKs, FKs, and timestamps are named the same way everywhere. That&amp;rsquo;s true — and also true that the convention enabling it exists only as long as someone is actively keeping it enforced.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Review.&lt;/strong&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. That&amp;rsquo;s true — and whether it actually gets flagged depends on whether the reviewer remembers the rule or a linter is enforcing it.&lt;/p&gt;&#xA;&lt;p&gt;&lt;strong&gt;Queries.&lt;/strong&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;&#xA;&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;&#xA;&lt;p&gt;Which means the reasons worth picking a convention are the reasons a machine can enforce it.&lt;/p&gt;&#xA;&lt;h2 id=&#34;why-it-matters-for-automation&#34;&gt;Why it matters for automation&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&lt;p&gt;&lt;strong&gt;Code generators produce better output.&lt;/strong&gt; &lt;a class=&#34;link&#34; href=&#34;https://sqlc.dev/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &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;&#xA;&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;&#xA;&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;&#xA;&lt;p&gt;&lt;strong&gt;Schema diffing and drift detection depend on consistent shape.&lt;/strong&gt; Tools like &lt;a class=&#34;link&#34; href=&#34;https://atlasgo.io/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;Atlas&lt;/a&gt; and &lt;a class=&#34;link&#34; href=&#34;https://www.skeema.io/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &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;&#xA;&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=&#34;link&#34; href=&#34;https://sqlfluff.com/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;SQLFluff&lt;/a&gt;, &lt;a class=&#34;link&#34; href=&#34;https://github.com/quarylabs/sqruff&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &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;&#xA;&lt;p&gt;&lt;strong&gt;Documentation generators&lt;/strong&gt; like &lt;a class=&#34;link&#34; href=&#34;https://github.com/k1LoW/tbls&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &gt;tbls&lt;/a&gt; and &lt;a class=&#34;link&#34; href=&#34;https://schemaspy.org/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &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;&#xA;&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;&#xA;&lt;div class=&#34;note-box&#34;&gt;&#xA;    &lt;strong&gt;The contract is implicit&lt;/strong&gt;&#xA;    &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;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;h2 id=&#34;the-menu--pick-what-automation-expects&#34;&gt;The menu — pick what automation expects&#xA;&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, and 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;&#xA;&lt;h3 id=&#34;naming-snake-vs-camel&#34;&gt;Naming: snake vs camel&#xA;&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;&#xA;&lt;h3 id=&#34;table-names-singular-or-plural&#34;&gt;Table names: singular or plural&#xA;&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 — 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;&#xA;&lt;h3 id=&#34;primary-keys-id-vs-table_id&#34;&gt;Primary keys: &lt;code&gt;id&lt;/code&gt; vs &lt;code&gt;&amp;lt;table&amp;gt;_id&lt;/code&gt;&#xA;&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;&#xA;&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;&#xA;&lt;h3 id=&#34;foreign-key-naming&#34;&gt;Foreign key naming&#xA;&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;&#xA;&lt;h3 id=&#34;mandatory-columns&#34;&gt;Mandatory columns&#xA;&lt;/h3&gt;&lt;p&gt;Decide which columns every table must have. Common choices:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;code&gt;created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()&lt;/code&gt; — row creation time.&lt;/li&gt;&#xA;&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;&#xA;&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;&#xA;&lt;li&gt;&lt;code&gt;deleted_at TIMESTAMPTZ&lt;/code&gt; — soft-delete marker.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;div class=&#34;warning-box&#34;&gt;&#xA;    &lt;strong&gt;Partial adoption is worse than none&lt;/strong&gt;&#xA;    &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;&#xA;&lt;/div&gt;&#xA;&#xA;&lt;h3 id=&#34;status-and-enum-patterns&#34;&gt;Status and enum patterns&#xA;&lt;/h3&gt;&lt;p&gt;Three common strategies, each with trade-offs:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;INT with documented values&lt;/strong&gt; — &lt;code&gt;status TINYINT NOT NULL COMMENT &#39;1=active, 2=paused, 3=cancelled&#39;&lt;/code&gt;. Compact, fast, relies on comments for semantics. Works across engines.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;CHECK constraint&lt;/strong&gt; — &lt;code&gt;status VARCHAR(20) CHECK (status IN (&#39;active&#39;, &#39;paused&#39;, &#39;cancelled&#39;))&lt;/code&gt;. Self-documenting in the DDL, slightly larger storage, human-readable in query results.&lt;/li&gt;&#xA;&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;&#xA;&lt;/ul&gt;&#xA;&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;&#xA;&lt;h3 id=&#34;boolean-prefixes&#34;&gt;Boolean prefixes&#xA;&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? Is it an adjective or a verb? Prefixing eliminates the ambiguity at no runtime cost.&lt;/p&gt;&#xA;&lt;h3 id=&#34;timestamp-types&#34;&gt;Timestamp types&#xA;&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;&#xA;&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;&#xA;&lt;h3 id=&#34;character-sets-and-collations&#34;&gt;Character sets and collations&#xA;&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;&#xA;&lt;h2 id=&#34;conventions-beyond-the-schema&#34;&gt;Conventions beyond the schema&#xA;&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;&#xA;&lt;p&gt;Common places this shows up:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&lt;/ul&gt;&#xA;&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. But 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;&#xA;&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, just in a different layer — the tooling to enforce them is different (IaC linters instead of SQLFluff), but the discipline is identical.&lt;/p&gt;&#xA;&lt;h2 id=&#34;enforcement-conventions-without-enforcement-decay&#34;&gt;Enforcement: conventions without enforcement decay&#xA;&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;&#xA;&lt;h3 id=&#34;schema-linters&#34;&gt;Schema linters&#xA;&lt;/h3&gt;&lt;p&gt;&lt;a class=&#34;link&#34; href=&#34;https://sqlfluff.com/&#34;  target=&#34;_blank&#34; rel=&#34;noopener&#34;&#xA;    &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;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;Naming rules (&lt;code&gt;snake_case&lt;/code&gt; only, specific prefixes/suffixes).&lt;/li&gt;&#xA;&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;&#xA;&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;&#xA;&lt;li&gt;Style (trailing commas, keyword casing, indentation).&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&lt;p&gt;The alternative is a custom linter — a script that parses migration files and checks them against a ruleset. This is more work to build but more flexible if the rules are unusual. Teams with strong opinions often end up here.&lt;/p&gt;&#xA;&lt;h3 id=&#34;ci-checks-on-the-schema-itself&#34;&gt;CI checks on the schema itself&#xA;&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;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;5&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;6&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- Every table in the application schema has created_at&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;SELECT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;table_name&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;FROM&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;information_schema&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;columns&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;table_schema&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;public&amp;#39;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;GROUP&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;BY&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;table_name&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;HAVING&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;COUNT&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;o&#34;&gt;*&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;FILTER&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;p&#34;&gt;(&lt;/span&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;column_name&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;created_at&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;)&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;mi&#34;&gt;0&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;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;&#xA;&lt;p&gt;Other useful assertions:&lt;/p&gt;&#xA;&lt;div class=&#34;highlight&#34;&gt;&lt;div class=&#34;chroma&#34;&gt;&#xA;&lt;table class=&#34;lntable&#34;&gt;&lt;tr&gt;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code&gt;&lt;span class=&#34;lnt&#34;&gt;1&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;2&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;3&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;4&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;5&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;6&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;7&#xA;&lt;/span&gt;&lt;span class=&#34;lnt&#34;&gt;8&#xA;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&#xA;&lt;td class=&#34;lntd&#34;&gt;&#xA;&lt;pre tabindex=&#34;0&#34; class=&#34;chroma&#34;&gt;&lt;code class=&#34;language-sql&#34; data-lang=&#34;sql&#34;&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- No table uses TIMESTAMP without timezone&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;SELECT&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;table_name&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;,&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;column_name&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;FROM&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;information_schema&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;.&lt;/span&gt;&lt;span class=&#34;n&#34;&gt;columns&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;k&#34;&gt;WHERE&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;data_type&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;timestamp without time zone&amp;#39;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;  &lt;/span&gt;&lt;span class=&#34;k&#34;&gt;AND&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;n&#34;&gt;table_schema&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;o&#34;&gt;=&lt;/span&gt;&lt;span class=&#34;w&#34;&gt; &lt;/span&gt;&lt;span class=&#34;s1&#34;&gt;&amp;#39;public&amp;#39;&lt;/span&gt;&lt;span class=&#34;p&#34;&gt;;&lt;/span&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;w&#34;&gt;&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- Every FK column has an index&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;span class=&#34;line&#34;&gt;&lt;span class=&#34;cl&#34;&gt;&lt;span class=&#34;c1&#34;&gt;-- (expensive to query but worth running on schedule)&#xA;&lt;/span&gt;&lt;/span&gt;&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;&lt;/td&gt;&lt;/tr&gt;&lt;/table&gt;&#xA;&lt;/div&gt;&#xA;&lt;/div&gt;&lt;p&gt;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;&#xA;&lt;h3 id=&#34;pre-commit-hooks&#34;&gt;Pre-commit hooks&#xA;&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;&#xA;&lt;h3 id=&#34;codeowners-on-migration-directories&#34;&gt;CODEOWNERS on migration directories&#xA;&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;&#xA;&lt;h3 id=&#34;review-templates&#34;&gt;Review templates&#xA;&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;&#xA;&lt;h3 id=&#34;scope-strict-for-new-lenient-for-legacy&#34;&gt;Scope: strict for new, lenient for legacy&#xA;&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;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;&lt;strong&gt;New tables&lt;/strong&gt; — linter is strict. No exceptions without a documented reason.&lt;/li&gt;&#xA;&lt;li&gt;&lt;strong&gt;Existing tables&lt;/strong&gt; — grandfathered. Linter skips them or only checks newly-added columns.&lt;/li&gt;&#xA;&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;&#xA;&lt;/ul&gt;&#xA;&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;&#xA;&lt;h2 id=&#34;the-hardest-part-changing-conventions-without-creating-a-new-one&#34;&gt;The hardest part: changing conventions without creating a new one&#xA;&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;&#xA;&lt;p&gt;The discipline is straightforward, even if it&amp;rsquo;s not always followed.&lt;/p&gt;&#xA;&lt;h3 id=&#34;write-the-convention-down&#34;&gt;Write the convention down&#xA;&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;&#xA;&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; — not &amp;ldquo;here&amp;rsquo;s the philosophy of relational design.&amp;rdquo; Short docs get read; long ones don&amp;rsquo;t.&lt;/p&gt;&#xA;&lt;h3 id=&#34;use-a-lightweight-rfc-process-for-changes&#34;&gt;Use a lightweight RFC process for changes&#xA;&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;&#xA;&lt;ul&gt;&#xA;&lt;li&gt;What&amp;rsquo;s changing and why.&lt;/li&gt;&#xA;&lt;li&gt;Impact on existing tables (migrate all, grandfather, or cutover by date).&lt;/li&gt;&#xA;&lt;li&gt;Impact on tools, ORMs, dashboards, and downstream consumers.&lt;/li&gt;&#xA;&lt;li&gt;Who decides (single decision-maker or review board).&lt;/li&gt;&#xA;&lt;li&gt;Explicit cutover date if changing for new work only.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&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;&#xA;&lt;h3 id=&#34;decide-migrate-grandfather-or-both&#34;&gt;Decide: migrate, grandfather, or both&#xA;&lt;/h3&gt;&lt;p&gt;Three options, each with a different risk profile:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&lt;/ul&gt;&#xA;&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;&#xA;&lt;h3 id=&#34;the-two-generation-rule&#34;&gt;The two-generation rule&#xA;&lt;/h3&gt;&lt;div class=&#34;note-box&#34;&gt;&#xA;    &lt;strong&gt;Two is the limit&lt;/strong&gt;&#xA;    &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;&#xA;&lt;/div&gt;&#xA;&#xA;&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;&#xA;&lt;h2 id=&#34;when-to-accept-legacy-drift&#34;&gt;When to accept legacy drift&#xA;&lt;/h2&gt;&lt;p&gt;Not every legacy convention is worth fixing. The calculation:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&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 — these are real costs, worth migrating.&lt;/li&gt;&#xA;&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;&#xA;&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;&#xA;&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;&#xA;&lt;/ul&gt;&#xA;&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;&#xA;&lt;h2 id=&#34;trade-offs&#34;&gt;Trade-offs&#xA;&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;&#xA;&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;&#xA;&lt;p&gt;The lightweight approach:&lt;/p&gt;&#xA;&lt;ul&gt;&#xA;&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;&#xA;&lt;li&gt;A larger set of advisory warnings, not blockers.&lt;/li&gt;&#xA;&lt;li&gt;A clear escape hatch for exceptions, with the exception documented.&lt;/li&gt;&#xA;&lt;li&gt;Periodic review — rules that fire too often are wrong, rules that never fire are noise.&lt;/li&gt;&#xA;&lt;/ul&gt;&#xA;&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;&#xA;&lt;h2 id=&#34;the-bigger-picture&#34;&gt;The bigger picture&#xA;&lt;/h2&gt;&lt;p&gt;The question isn&amp;rsquo;t &amp;ldquo;what&amp;rsquo;s the right convention.&amp;rdquo; It&amp;rsquo;s &amp;ldquo;what does our automation need, and can a machine enforce it?&amp;rdquo; 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;&#xA;&lt;p&gt;The schemas that age well aren&amp;rsquo;t the ones with the best-designed conventions. They&amp;rsquo;re the ones where the only conventions that exist 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 not a failure of discipline. It&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;&#xA;&lt;p&gt;The test for any proposed convention: which automation will fail if this is violated? If nothing fails, the convention doesn&amp;rsquo;t need to exist. If something fails, wire that automation into CI and the convention takes care of itself. Everything between those two — &amp;ldquo;it would be nice if we all agreed to…&amp;rdquo; — is work that produces no durable outcome, no matter how strongly anyone feels about the right answer.&lt;/p&gt;&#xA;</description>
        </item></channel>
</rss>
