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 — ALTER TABLE users RENAME COLUMN country TO — and every database has supported it for decades, preserving the
nationality
data, in a single statement.
So why does it feel dangerous?
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.
What the tools do
Prisma 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 --create-only, open the SQL file, replace what it
produced with a RENAME, 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.
Entity Framework Core behaves the same way, and says so in as many words: it
"is generally unable to detect" that a rename occurred. You run add-migration,
then edit the scaffolded operations by hand into migrationBuilder.RenameColumn.
Doctrine will often produce a CHANGE 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
doctrine:schema:update may simply add the new column and drop the old one, and
that your data goes with it.
Django is better, and the difference is instructive. It generates
RenameField, which preserves data. But it cannot decide on its own: it stops
and asks, Did you rename dinner.center to dinner.bottom_center? [y/N]. Under
--noinput 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.
Rails and Laravel are honest in a different way. They do not try to infer
anything. You write rename_column :posts, :body, :content yourself, in a file
you created for the purpose. The data survives because you told it exactly what
to do.
Strapi 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.
The pattern
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.
And the interesting thing is that the information is sufficient. The old model
had an attribute named country. The new model has one named nationality,
with the same type, in the same entity, and nothing named country anymore. A
person reading those two models side by side knows what happened. The tool
cannot, because it never sees two models — it sees the current one, and infers
the delta from a database it introspects.
This is why the migration file exists. It is not there because schema change is
inherently dangerous. It is there because the tool has lost the information that
would make the change obvious, and the file is where you give it back.
The other way
There is a design where the model is versioned, and migration is the difference
between two versions of it.
This is not new. Core Data has worked this way on Apple platforms since 2005,
and its predecessor at NeXT goes back further. You keep model versions. When the
store opens against a newer one, the framework compares them and infers a
mapping. Renaming an attribute is expressed by marking the new one as a renaming
of the old, and the value moves. Nothing is generated, and nothing needs
running: the migration happens when the store is opened, because the framework
has both versions in front of it.
The consequence worth naming is that it 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.
I have spent about seven years building this for PHP — a Foundation layer, a
Core Data layer, and a service framework on top. 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.
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.
The test that covers the case in this article is
testRenamingAnAttributeRenamesTheColumnAndCarriesData. There is a companion for
renaming an entity, and others for relationship transitions, many-to-many
changes, and derived columns.
What about the changes inference gets wrong
This is the right objection, and a framework that only does the easy case is not
usable in production.
Inference is the default, not the only path. A change it cannot derive on its own
— an attribute becoming non-optional, where existing nulls need a value before
the constraint can apply, or a column whose new value has to be computed from the
old one — 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.
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.
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.
Why this is rare
If this design has existed since 2005 and works, the obvious question is why
server-side frameworks do not do it.
Part of the answer is that it is genuinely hard. Inferring a mapping between two
model versions, deciding which changes are safe to derive and which are not, and
moving data without losing it is a large amount of work for a problem most teams
have learned to live with. Apple spent years on it with a team.
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.
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.
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.
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.
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.
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.
What I am claiming, and what I am not
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.
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 model, and once you make versions of the model first-class, most of
what the file was for goes away.
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.
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.
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 github.com/dantesabatier/Singularity.
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.
Top comments (0)