<?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 developers approach carefully, and newcomers approach without knowing they should. The mechanics are trivial — &lt;code&gt;ALTER TABLE users RENAME COLUMN country TO nationality&lt;/code&gt; — and every database has supported it for decades, preserving the 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 then something else decides what SQL your change becomes. That indirection is 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 explicit about the consequence: the default migration drops the old column and adds a new one, and the data goes with it. The prescribed workflow is to generate the migration with &lt;code&gt;--create-only&lt;/code&gt;, open the SQL file, replace what it produced with a &lt;code&gt;RENAME&lt;/code&gt;, and only then apply it. The tool knows it cannot infer 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 "is generally unable to detect" that a rename occurred. You run &lt;code&gt;add-migration&lt;/code&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" is doing real work in that sentence — the diff detects rename intent in many situations and not all. The framework's own teaching material warns that &lt;code&gt;doctrine:schema:update&lt;/code&gt; may simply add the new column and drop the old one, and 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;code&gt;RenameField&lt;/code&gt;, which preserves data. But it cannot decide on its own: it stops and asks, &lt;code&gt;Did you rename dinner.center to dinner.bottom_center? [y/N]&lt;/code&gt;. Under &lt;code&gt;--noinput&lt;/code&gt; it does not rename at all — it falls back to drop and create. The safe path requires a person at a prompt, which is another way of saying it 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 anything. You write &lt;code&gt;rename_column :posts, :body, :content&lt;/code&gt; yourself, in a file you created for the purpose. The data survives because you told it exactly what 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 model and the schema follows" than the ORMs. Renaming a field there adds a new column of nulls and drops the old one. The issue has been open since 2021, filed 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, review, or at minimum run. The sophisticated tools generate a draft of that file. The honest ones make you write it. Not one of them treats "the model changed" as sufficient information to migrate.&lt;/p&gt;

&lt;p&gt;And the interesting thing is that the information &lt;em&gt;can&lt;/em&gt; be sufficient — if the model is made to carry it. Two independent models, compared from the outside, are genuinely ambiguous: a dropped &lt;code&gt;country&lt;/code&gt; and an added &lt;code&gt;nationality&lt;/code&gt; of the same type could be a rename or two unrelated changes that happen to line up. What resolves the ambiguity is not cleverness in the diff — it is that the model itself records the intent: a renaming identifier, sitting next to the index and uniqueness declarations, that says the new attribute &lt;em&gt;is&lt;/em&gt; the old one under a new name. Read that way, the tool is not guessing. It is reading something the model already told it.&lt;/p&gt;

&lt;p&gt;This is why the migration file exists in every tool above. Not because schema change is inherently dangerous, but because none of them persists a full prior version of the model with that kind of intent attached. They reconstruct the old shape from a database they introspect, which has already lost the thing that would make the change obvious. The file is where a person gives that information 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 between two versions of it.&lt;/p&gt;

&lt;p&gt;Concretely: a full version of the model is cached alongside the store. A version hash decides, cheaply, whether the store is still compatible with the current model — not whether anything changed. The hash covers what affects storage: names, types, optionality, transient and derived flags, bounds. A change that does not alter the stored shape leaves the store valid and migrates nothing, which is why editing a model does not, by itself, imply a migration. The inverse is available too: a version hash modifier forces a change to count when nothing structural did — when the stored shape is identical but the meaning of what is stored is not, and the data needs to go through the migration anyway. When the hashes do diverge, the cached version and the current one are compared in full — entity by entity, attribute by attribute — and every element falls into one of three buckets: unchanged and copied as-is, removed, or changed. Renaming an attribute is expressed by marking the new one as a renaming of the old, so it lands in the third bucket with its mapping already known rather than guessed. That mapping — the light migration — is inferred from the two versions; nothing is generated as text and nothing needs to be run, because the migration happens when the store is opened, comparing versions the framework already has in hand.&lt;/p&gt;

&lt;p&gt;Worth being precise about the layers, because "inference" undersells what is happening. What gets inferred is the &lt;em&gt;map&lt;/em&gt; between the base model and the target model — which elements are equivalent, which are new, which are gone — and it is a rich one, because the model carries far more than names: types, optionality, transient and derived properties, indexes, uniqueness, renaming identifiers. Most structural changes, including an attribute becoming non-optional, are resolved from that alone. What the model cannot know is what your &lt;em&gt;data&lt;/em&gt; means. An attribute that becomes composite is the clear case: the model says the new shape has several components, but only you know how each existing value decomposes into them. The same goes for a value that has to be computed from the old one, or two entities merged into one. For those the map is given explicitly instead. That is the heavy migration: same mechanism, an explicit map instead of an inferred one, not an escape hatch beside it.&lt;/p&gt;

&lt;p&gt;Also worth naming: none of this is really about databases. The concept underneath is a persistent store, not a database — the model and the migration mechanism are defined against that abstraction. A store backed by SQL is one concrete implementation, incremental in nature; a store backed by an atomic format like XML, read and written whole, is another. Both are fully provided, so the person designing the model never touches either implementation directly. &lt;code&gt;ALTER TABLE&lt;/code&gt; above is the visible, familiar face of one such store — not the level at which any of this actually operates.&lt;/p&gt;

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

&lt;p&gt;I have spent about seven years building this for PHP — a Foundation layer, a Core Data layer, and a service framework on top, including both store implementations. In that time I have not written a migration. Not "wrote few" — the mechanism does not have a place to put one. Attributes have been renamed, entities have been renamed, relationships have changed cardinality, attributes have become non-optional. The model changed, and 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 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;code&gt;testRenamingAnAttributeRenamesTheColumnAndCarriesData&lt;/code&gt;. There is a companion for renaming an entity, and others for relationship transitions, many-to-many 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 usable in production.&lt;/p&gt;

&lt;p&gt;Inference is the default, not the only path. Because the model records types, optionality, derived and transient properties, indexes and uniqueness, most changes are resolved without help. What it cannot derive is the meaning of the data itself — an attribute that becomes composite, a new value computed from the old one, entities merged — and that takes an explicit mapping between the two versions. Below that, a migration stage with handlers that run before and after it, so the data can be prepared and cleaned up. Below that, a policy per entity with a hook at each phase: instance creation, relationship creation, validation. And the migration manager itself is a replaceable class, the same way the row cache and the snapshot mapper are.&lt;/p&gt;

&lt;p&gt;What matters is that these are the same mechanism, not an escape hatch beside it. Reaching for control does not mean leaving the model behind and hand-writing SQL 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 whether or not you wrote the migration, and a complex change still deserves a plan and a rehearsal against a copy. Modelling is a skill: knowing when a sub-entity is right, when to declare a uniqueness constraint, where an index earns its keep, how far to normalise. Inference does not supply any of that. What it removes is the cost of executing the decision once you have made it — and, more 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 works, the obvious question is why server-side frameworks do not do it.&lt;/p&gt;

&lt;p&gt;Part of the answer is that it is genuinely hard. Persisting full model versions, inferring a map between two of them, deciding which changes are safe to derive and which are not, and moving data without losing it — across more than one kind of store — is a large amount of work for a problem most teams have learned to live with.&lt;/p&gt;

&lt;p&gt;Part of it is history. Rails established the migration file in the mid-2000s and nearly every framework since has copied the shape, in every language. It became 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 to a migration. They lose twenty minutes, several times a year, plus a background level of caution around schema change that never quite goes away. That is not 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, people stop changing the schema. Columns keep names that stopped describing what they hold two years ago. A table that should have been split stays whole. A relationship that should be a real relationship gets resolved in application code instead, because fixing it properly meant a migration nobody wanted to write. None of these are decisions anyone made; they are decisions that were deferred until deferring became permanent. That is what friction does to a schema over years, and it is invisible precisely because it shows up as an absence of changes 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 writing migrations. The value turned out to be that the model stays a draft — something you can still be wrong about, and rewrite until the thing on paper 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 write each property — was added two and a half years after the first commit. It is not a column somewhere out of the way: it hangs off the base class that attributes, relationships and fetched properties all inherit from, which is the busiest junction in the model. The same change removed an entity that had been there since the beginning and replaced the structure that had stood in for it. 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 design was clear — but because reorganising the root of a live hierarchy means writing a migration you would rather not be responsible for. It took no migration file here, which is why it got done at the point I understood what it should be, 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 a real virtue: they are reviewable. A team can see the schema change in a pull request, discuss it, and roll it back as a unit. If that matters more to you 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 because most tools compare a model against a database instead of against a previous, fully-preserved version of the model — one carrying its own intent, not just its shape. Once you make versions of the model first-class, most of 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 is not one system either. The largest model I run on it has around sixty entities and has been in production for about three years, in a business that does not care what its backend is written in — orders, production scheduling, machines, invoicing. It has been extended continuously over that time, by the usual pressure of a real business changing its mind. It has never needed a heavy migration.&lt;/p&gt;

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




&lt;p&gt;The stack is MIT licensed and on Packagist: Foundation, CoreData and Service. The editor that designs the models, and generates the project that serves them, is at &lt;a href="https://github.com/dantesabatier/Singularity" rel="noopener noreferrer"&gt;github.com/dantesabatier/Singularity&lt;/a&gt;. There is a twenty-second clip at the top of that README showing an attribute 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>
