<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Dante</title>
    <description>The latest articles on DEV Community by Dante (@dantesabatier).</description>
    <link>https://dev.to/dantesabatier</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4150970%2F1dba0046-9912-4f94-ad51-9a1a714e99ff.png</url>
      <title>DEV Community: Dante</title>
      <link>https://dev.to/dantesabatier</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/dantesabatier"/>
    <language>en</language>
    <item>
      <title>Seven years without writing a migration</title>
      <dc:creator>Dante</dc:creator>
      <pubDate>Tue, 29 Sep 2026 20:50:04 +0000</pubDate>
      <link>https://dev.to/dantesabatier/seven-years-without-writing-a-migration-325d</link>
      <guid>https://dev.to/dantesabatier/seven-years-without-writing-a-migration-325d</guid>
      <description>&lt;p&gt;Renaming a column in production is one of those tasks that experienced&lt;br&gt;
developers approach carefully, and newcomers approach without knowing they&lt;br&gt;
should. The mechanics are trivial — &lt;code&gt;ALTER TABLE users RENAME COLUMN country TO&lt;br&gt;
nationality&lt;/code&gt; — and every database has supported it for decades, preserving the&lt;br&gt;
data, in a single statement.&lt;/p&gt;

&lt;p&gt;So why does it feel dangerous?&lt;/p&gt;

&lt;p&gt;Because in most stacks you do not write that statement. You change a model, and&lt;br&gt;
then something else decides what SQL your change becomes. That indirection is&lt;br&gt;
where the risk lives, and it is worth looking at what each tool actually does.&lt;/p&gt;

&lt;h2&gt;
  
  
  What the tools do
&lt;/h2&gt;

&lt;p&gt;&lt;strong&gt;Prisma&lt;/strong&gt; cannot tell a rename from a delete-and-create. Its documentation is&lt;br&gt;
explicit about the consequence: the default migration drops the old column and&lt;br&gt;
adds a new one, and the data goes with it. The prescribed workflow is to&lt;br&gt;
generate the migration with &lt;code&gt;--create-only&lt;/code&gt;, open the SQL file, replace what it&lt;br&gt;
produced with a &lt;code&gt;RENAME&lt;/code&gt;, and only then apply it. The tool knows it cannot infer&lt;br&gt;
your intent, so it hands you the file and asks you to correct it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Entity Framework Core&lt;/strong&gt; behaves the same way, and says so in as many words: it&lt;br&gt;
"is generally unable to detect" that a rename occurred. You run &lt;code&gt;add-migration&lt;/code&gt;,&lt;br&gt;
then edit the scaffolded operations by hand into &lt;code&gt;migrationBuilder.RenameColumn&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Doctrine&lt;/strong&gt; will often produce a &lt;code&gt;CHANGE&lt;/code&gt; that preserves the column, but "often"&lt;br&gt;
is doing real work in that sentence — the diff detects rename intent in many&lt;br&gt;
situations and not all. The framework's own teaching material warns that&lt;br&gt;
&lt;code&gt;doctrine:schema:update&lt;/code&gt; may simply add the new column and drop the old one, and&lt;br&gt;
that your data goes with it.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Django&lt;/strong&gt; is better, and the difference is instructive. It generates&lt;br&gt;
&lt;code&gt;RenameField&lt;/code&gt;, which preserves data. But it cannot decide on its own: it stops&lt;br&gt;
and asks, &lt;code&gt;Did you rename dinner.center to dinner.bottom_center? [y/N]&lt;/code&gt;. Under&lt;br&gt;
&lt;code&gt;--noinput&lt;/code&gt; it does not rename at all — it falls back to drop and create. The&lt;br&gt;
safe path requires a person at a prompt, which is another way of saying it&lt;br&gt;
cannot happen unattended.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Rails&lt;/strong&gt; and &lt;strong&gt;Laravel&lt;/strong&gt; are honest in a different way. They do not try to infer&lt;br&gt;
anything. You write &lt;code&gt;rename_column :posts, :body, :content&lt;/code&gt; yourself, in a file&lt;br&gt;
you created for the purpose. The data survives because you told it exactly what&lt;br&gt;
to do.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Strapi&lt;/strong&gt; has a visual content-type builder, which puts it closer to "change the&lt;br&gt;
model and the schema follows" than the ORMs. Renaming a field there adds a new&lt;br&gt;
column of nulls and drops the old one. The issue has been open since 2021, filed&lt;br&gt;
repeatedly. The pull request that fixes it works by generating migration files.&lt;/p&gt;

&lt;h2&gt;
  
  
  The pattern
&lt;/h2&gt;

&lt;p&gt;Look at what these have in common. Every one of them ends at a file you write,&lt;br&gt;
review, or at minimum run. The sophisticated tools generate a draft of that file.&lt;br&gt;
The honest ones make you write it. Not one of them treats "the model changed" as&lt;br&gt;
sufficient information to migrate.&lt;/p&gt;

&lt;p&gt;And the interesting thing is that the information &lt;em&gt;is&lt;/em&gt; sufficient. The old model&lt;br&gt;
had an attribute named &lt;code&gt;country&lt;/code&gt;. The new model has one named &lt;code&gt;nationality&lt;/code&gt;,&lt;br&gt;
with the same type, in the same entity, and nothing named &lt;code&gt;country&lt;/code&gt; anymore. A&lt;br&gt;
person reading those two models side by side knows what happened. The tool&lt;br&gt;
cannot, because it never sees two models — it sees the current one, and infers&lt;br&gt;
the delta from a database it introspects.&lt;/p&gt;

&lt;p&gt;This is why the migration file exists. It is not there because schema change is&lt;br&gt;
inherently dangerous. It is there because the tool has lost the information that&lt;br&gt;
would make the change obvious, and the file is where you give it back.&lt;/p&gt;

&lt;h2&gt;
  
  
  The other way
&lt;/h2&gt;

&lt;p&gt;There is a design where the model is versioned, and migration is the difference&lt;br&gt;
between two versions of it.&lt;/p&gt;

&lt;p&gt;This is not new. Core Data has worked this way on Apple platforms since 2005,&lt;br&gt;
and its predecessor at NeXT goes back further. You keep model versions. When the&lt;br&gt;
store opens against a newer one, the framework compares them and infers a&lt;br&gt;
mapping. Renaming an attribute is expressed by marking the new one as a renaming&lt;br&gt;
of the old, and the value moves. Nothing is generated, and nothing needs&lt;br&gt;
running: the migration happens when the store is opened, because the framework&lt;br&gt;
has both versions in front of it.&lt;/p&gt;

&lt;p&gt;The consequence worth naming is that it is unattended. Not "one command instead&lt;br&gt;
of two" — no command, and nobody present. In the editor I use to design these&lt;br&gt;
models, the migration happens inside the same save that commits the model edit.&lt;br&gt;
There is no migrate button, no separate step, no moment where the application&lt;br&gt;
stops to do something important. Something that is genuinely serious becomes&lt;br&gt;
part of something trivial, which is the highest compliment I know how to pay a&lt;br&gt;
piece of infrastructure.&lt;/p&gt;

&lt;p&gt;I have spent about seven years building this for PHP — a Foundation layer, a&lt;br&gt;
Core Data layer, and a service framework on top. In that time I have not written&lt;br&gt;
a migration. Not "wrote few" — the mechanism does not have a place to put one.&lt;br&gt;
Attributes have been renamed, entities have been renamed, relationships have&lt;br&gt;
changed cardinality, attributes have become non-optional. The model changed, and&lt;br&gt;
the schema and the data followed.&lt;/p&gt;

&lt;p&gt;That covers more than the framework's own schema. The largest model running on&lt;br&gt;
it has around sixty entities, in daily production use for about three years.&lt;/p&gt;

&lt;p&gt;The test that covers the case in this article is&lt;br&gt;
&lt;code&gt;testRenamingAnAttributeRenamesTheColumnAndCarriesData&lt;/code&gt;. There is a companion for&lt;br&gt;
renaming an entity, and others for relationship transitions, many-to-many&lt;br&gt;
changes, and derived columns.&lt;/p&gt;

&lt;h2&gt;
  
  
  What about the changes inference gets wrong
&lt;/h2&gt;

&lt;p&gt;This is the right objection, and a framework that only does the easy case is not&lt;br&gt;
usable in production.&lt;/p&gt;

&lt;p&gt;Inference is the default, not the only path. A change it cannot derive on its own&lt;br&gt;
— an attribute becoming non-optional, where existing nulls need a value before&lt;br&gt;
the constraint can apply, or a column whose new value has to be computed from the&lt;br&gt;
old one — takes an explicit mapping between the two versions. Below that, a&lt;br&gt;
migration stage with handlers that run before and after it, so the data can be&lt;br&gt;
prepared and cleaned up. Below that, a policy per entity with a hook at each&lt;br&gt;
phase: instance creation, relationship creation, validation. And the migration&lt;br&gt;
manager itself is a replaceable class, the same way the row cache and the&lt;br&gt;
snapshot mapper are.&lt;/p&gt;

&lt;p&gt;What matters is that these are the same mechanism, not an escape hatch beside it.&lt;br&gt;
Reaching for control does not mean leaving the model behind and hand-writing SQL&lt;br&gt;
in a file that the model no longer knows about.&lt;/p&gt;

&lt;p&gt;None of this removes the judgement. Migrating a production database is serious&lt;br&gt;
whether or not you wrote the migration, and a complex change still deserves a&lt;br&gt;
plan and a rehearsal against a copy. Modelling is a skill: knowing when a&lt;br&gt;
sub-entity is right, when to declare a uniqueness constraint, where an index&lt;br&gt;
earns its keep, how far to normalise. Inference does not supply any of that. What&lt;br&gt;
it removes is the cost of executing the decision once you have made it — and, more&lt;br&gt;
to the point, the cost of changing your mind about it later.&lt;/p&gt;

&lt;h2&gt;
  
  
  Why this is rare
&lt;/h2&gt;

&lt;p&gt;If this design has existed since 2005 and works, the obvious question is why&lt;br&gt;
server-side frameworks do not do it.&lt;/p&gt;

&lt;p&gt;Part of the answer is that it is genuinely hard. Inferring a mapping between two&lt;br&gt;
model versions, deciding which changes are safe to derive and which are not, and&lt;br&gt;
moving data without losing it is a large amount of work for a problem most teams&lt;br&gt;
have learned to live with. Apple spent years on it with a team.&lt;/p&gt;

&lt;p&gt;Part of it is history. Rails established the migration file in the mid-2000s and&lt;br&gt;
nearly every framework since has copied the shape, in every language. It became&lt;br&gt;
the obvious way to do it, which is different from being the right way.&lt;/p&gt;

&lt;p&gt;And part of it, I suspect, is that the pain is distributed. Nobody loses a week&lt;br&gt;
to a migration. They lose twenty minutes, several times a year, plus a background&lt;br&gt;
level of caution around schema change that never quite goes away. That is not&lt;br&gt;
enough to make anyone rebuild their ORM.&lt;/p&gt;

&lt;p&gt;But the twenty minutes are not the real cost. When changing the schema hurts,&lt;br&gt;
people stop changing the schema. Columns keep names that stopped describing what&lt;br&gt;
they hold two years ago. A table that should have been split stays whole. A&lt;br&gt;
relationship that should be a real relationship gets resolved in application code&lt;br&gt;
instead, because fixing it properly meant a migration nobody wanted to write.&lt;br&gt;
None of these are decisions anyone made; they are decisions that were deferred&lt;br&gt;
until deferring became permanent. That is what friction does to a schema over&lt;br&gt;
years, and it is invisible precisely because it shows up as an absence of changes&lt;br&gt;
rather than as a bad one.&lt;/p&gt;

&lt;p&gt;Which is the part I did not expect when I started. I assumed the value was not&lt;br&gt;
writing migrations. The value turned out to be that the model stays a draft —&lt;br&gt;
something you can still be wrong about, and rewrite until the thing on paper&lt;br&gt;
actually matches what the system needs to be.&lt;/p&gt;

&lt;p&gt;A concrete case, from the editor's own model. Access control — who may read and&lt;br&gt;
write each property — was added two and a half years after the first commit. It&lt;br&gt;
is not a column somewhere out of the way: it hangs off the base class that&lt;br&gt;
attributes, relationships and fetched properties all inherit from, which is the&lt;br&gt;
busiest junction in the model. The same change removed an entity that had been&lt;br&gt;
there since the beginning and replaced the structure that had stood in for it.&lt;br&gt;
Two years of data were already in that store.&lt;/p&gt;

&lt;p&gt;That is exactly the change a team defers. Not because it is hard to design — the&lt;br&gt;
design was clear — but because reorganising the root of a live hierarchy means&lt;br&gt;
writing a migration you would rather not be responsible for. It took no migration&lt;br&gt;
file here, which is why it got done at the point I understood what it should be,&lt;br&gt;
rather than at the point I could afford to find out.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I am claiming, and what I am not
&lt;/h2&gt;

&lt;p&gt;I am not claiming this is the only correct design. Explicit migration files have&lt;br&gt;
a real virtue: they are reviewable. A team can see the schema change in a pull&lt;br&gt;
request, discuss it, and roll it back as a unit. If that matters more to you&lt;br&gt;
than not writing them, the tradeoff is defensible.&lt;/p&gt;

&lt;p&gt;What I am claiming is narrower: the file is not a law of nature. It exists&lt;br&gt;
because most tools compare a model against a database instead of against a&lt;br&gt;
previous model, and once you make versions of the model first-class, most of&lt;br&gt;
what the file was for goes away.&lt;/p&gt;

&lt;p&gt;Seven years is not proof that this scales to every team or every schema. But it&lt;br&gt;
is not one system either. The largest model I run on it has around sixty&lt;br&gt;
entities and has been in production for about three years, in a business that&lt;br&gt;
does not care what its backend is written in — orders, production scheduling,&lt;br&gt;
machines, invoicing. It has been extended continuously over that time, by the&lt;br&gt;
usual pressure of a real business changing its mind. It has never needed a heavy&lt;br&gt;
migration.&lt;/p&gt;

&lt;p&gt;And I would not read that as the model having been right the first time. It was&lt;br&gt;
not. It is that being wrong stayed cheap for long enough that the corrections&lt;br&gt;
actually got made.&lt;/p&gt;




&lt;p&gt;The stack is MIT licensed and on Packagist: Foundation, CoreData and Service.&lt;br&gt;
The editor that designs the models, and generates the project that serves them,&lt;br&gt;
is at &lt;a href="https://github.com/dantesabatier/Singularity" rel="noopener noreferrer"&gt;github.com/dantesabatier/Singularity&lt;/a&gt;.&lt;br&gt;
There is a twenty-second clip at the top of that README showing an attribute&lt;br&gt;
renamed in the editor and read back over HTTP under its new name, in one take.&lt;/p&gt;

</description>
      <category>php</category>
      <category>database</category>
      <category>architecture</category>
      <category>webdev</category>
    </item>
  </channel>
</rss>
