Changing a Strapi schema without losing data
Remove a field from a Strapi content type, restart, and its column is gone from the database along with everything in it. No prompt, no dry run, no confirmation. That's Strapi's schema sync working as designed. You make the change in development, where the Content-Type Builder lives, but the sync runs on every boot: the same drop lands in production the moment the new schema deploys. You can turn it off with one line of config, and once you have, every destructive change becomes something a person decides and reviews. Here's the dangerous default, the guard that disables it, and what a real production change looks like once the framework is no longer dropping columns behind your back.
Likely causes · Most → least common
Cause 01 of 02
Renaming a field drops the old column
You rename a field in the Content-Type Builder, deploy, and its values are gone. The new field is there under the new name, correctly typed, and completely empty.
In the schema it's a one-word edit, the attribute key going from summary to excerpt:
// before
"summary": { "type": "text" }
// after
"excerpt": { "type": "text" }Strapi has no concept of a rename. It sees summary gone and excerpt added, so the sync drops the old column, data included, and creates a fresh, empty one:
SELECT id, summary FROM articles LIMIT 5;
-- ERROR: column "summary" does not existStrapi keeps the database in sync with your content-type schemas on every boot, and 'in sync' has no memory of what a column used to be called. You only edit the schema in development, since the Content-Type Builder is disabled in production, but that's not the safeguard it sounds like: the schema travels to production as code and the same sync runs there on the next deploy. A rename that looked harmless in review empties the column when it ships.
Two moves, both below. The guard stops the sync dropping the old column. And an expand-and-contract migration is how you actually rename a field, carrying the data across to the new column instead of leaving it behind.
Cause 02 of 02
Restoring a dropped field brings back an empty column
A field that was removed on one deploy and added back on a later one returns empty. The schema looks identical to how it started, the admin panel shows the field again, and every value that used to be in it is gone.
Picture a subtitle field that looks unused. One branch removes it, merges, and deploys, and the sync drops the column with everything in it. A fortnight later someone needs it back, so a second branch re-adds subtitle to the schema and deploys again. The field returns to the API and the admin panel, the column is recreated, and it's blank.
In Git the history reads as removed then restored, the kind of diff you'd call reversible. The database doesn't agree: the data left on the deploy that dropped the column, and re-adding the field only gives you a fresh, blank one. Reverting the code is not reverting the data.
Check the deploy history for a window where the field was absent from the schema. If there was one, the column was dropped during it. A git log on schema.json showing the field removed and later re-added is the tell.
The guard below prevents the first drop: with forceMigration: false, removing the field leaves its column in place, so restoring the field later finds the data intact. Once a column has already been dropped, only a backup brings it back.
02· The guard
Disable destructive sync with one line
Strapi's database config has a settings.forceMigration flag. It defaults to true, which lets the schema sync drop tables, columns, indexes and foreign keys that are no longer in your schemas. Set it to false and every drop is skipped.
In config/database.ts, add the block to the returned object:
return {
connection: {
client,
...connections[client],
acquireConnectionTimeout: env.int('DATABASE_CONNECTION_TIMEOUT', 60000),
},
settings: {
forceMigration: false,
},
};03
What that one line changes
Restart, remove a field that has data behind it, and restart again. This time the column and its data are still there. Strapi has stopped reading and writing that column, but it hasn't touched it.
The default has gone from destroy to leave alone. Removing a field from the schema now takes it out of the API and the admin panel, and nothing else. The column stays until you drop it yourself, in a migration you write and review, on a schedule you choose. A rename behaves the same way: the old column keeps its data under the old name, and a migration moves it across.
Reach for this sparingly, because it's a blunt instrument. It blocks every drop, not just the risky one, so the database quietly keeps columns Strapi has forgotten about. And the skip is one-way: once a drop is skipped, the schema sync still records the new schema as its reference, so that column stays untracked even if you set the flag back to true later. Strapi won't clean it up for you. Treat forceMigration: false as a deliberate guard around a change you're nervous about, not a switch you leave on everywhere, and make the real change through a migration, which is targeted. The flag is documented under settings in the database configuration reference.
One rule sits underneath all of this: back up before you change a schema on data you care about. A dump you can actually restore from turns every step below from a risk into a rehearsal. Run pg_dump before the deploy, confirm the restore works on a scratch database, and a bad migration becomes an inconvenience instead of an incident.
04· The migration
A real change: expand, backfill, contract
Turning off destructive sync tells the framework what not to do. It doesn't tell you how to make a schema change safely. For that the pattern is expand and contract, and Strapi's migration runner is what makes the backfill work.
Say you're moving price, stored as a decimal, to a priceCents integer, so floating-point rounding stops turning £15.00 into £14.999998 somewhere downstream. You don't rename the column in place and hope. You expand first: add the new field alongside the old one, backfill it, and let both live at once.
Add priceCents to the schema and keep price:
"priceCents": {
"type": "integer",
"pluginOptions": { "i18n": { "localized": true } }
},05
The migration reads the old shape
With the new field declared, add the backfill. Strapi runs plain JavaScript migration files from database/migrations in filename order on startup, before it syncs the schema. That ordering is the whole trick: inside up, the database is still in its old shape, so the migration can read the old price column to populate the new price_cents one. It creates the column, backfills it, and Strapi records the run so it never happens twice. The database migrations reference documents the file naming, the ordering, and the fact that each migration runs inside its own transaction:
'use strict';
module.exports = {
async up(knex) {
const hasOld = await knex.schema.hasColumn('products', 'price');
const hasNew = await knex.schema.hasColumn('products', 'price_cents');
// Migrations run BEFORE the schema sync, so the new column does not
// exist yet on first boot. Create it here, then backfill from the old one.
if (hasOld && !hasNew) {
await knex.schema.alterTable('products', (t) => {
t.integer('price_cents');
});
await knex('products')
.whereNotNull('price')
.update({ price_cents: knex.raw('price * 100') });
}
},
};06
What the next boot shows
Restart Strapi. The boot log shows the migration running before anything else touches the schema, and a query confirms the backfill. 1500 becomes 150000, with both columns live:
[internal migration]: migrating 2026.09.16T00.00.00.backfill-price-cents.js
strapi=# SELECT id, name, price, price_cents FROM products LIMIT 3;
id | name | price | price_cents
----+---------+-------+-------------
1 | Starter | 1500 | 150000
2 | Pro | 4900 | 490000
3 | Team | 9900 | 990000One naming detail trips people up: Strapi stores the priceCents attribute as a price_cents column, converting camelCase to snake_case. Migrations work at the database level, so you use the column name, not the attribute name.
07· The rule
Never stop writing a column and drop it in the same release
Expand is only the first half. With both columns live, old code keeps reading price while new code switches to priceCents. You deploy that, let it settle, and confirm nothing reads the old field any more. Only then, in a later release, do you contract: remove price from the schema and drop the column in a second migration.
The discipline exists because Strapi migrations only run forward. There's no automatic down step that puts the data back, so reverting is a manual job. That's not a gap to apologise for. It's the reason the safe path is expand-and-contract rather than rename-and-pray: every step rolls forward, each one is small enough to reason about, and at no point does a running instance read a column that no longer exists.
Strapi does not support down migrations. If you need to revert a migration, you have to do it manually. Down migrations are planned, but no timeline is currently available.
If you drop the old column in the same deploy that stops writing it, a rollback leaves the previous version reading a column that's gone. You've turned a code rollback into a data incident. Keep the two changes in separate releases, with the backfill proven in between.
Common questions
Treat it as expand-and-contract, not a rename. Add the new field, write a migration that copies the old column into it, deploy and let both live while code moves over, then drop the old field in a later release. Renaming in place looks like one edit but reads to Strapi as a delete plus an add, which drops the old column and its data.
Use it sparingly. It's a blunt instrument that blocks every drop, not just the one you're worried about, so left on permanently your database quietly accumulates columns and tables Strapi no longer tracks. And the skip is one-way: once a drop is skipped, that object stays untracked even if you set the flag back to true later, so Strapi won't remove it for you. Reach for it as a deliberate guard around a risky change, then make the actual change through a migration, which is targeted rather than global.
In database/migrations, as .js files named YYYY.MM.DDTHH.MM.SS.description.js so the alphabetical order sets the run order. Strapi runs them on start and develop, before the schema sync, each inside its own transaction, and records applied ones so they never run twice.
Strapi converts camelCase attribute names to snake_case columns, so the priceCents attribute is a price_cents column in the database. Migrations operate at the database level, so they use the column names rather than the schema attribute names.
Not automatically. Strapi's runner only executes up, forward, so reverting a migration is a manual job. The docs note that down migrations are planned but have no timeline yet. This is the whole reason for expand-and-contract: every change rolls forward and nothing needs undoing, so if a step is wrong you fix it with another forward migration.
Yes. Migrations run on deploy the same way they do locally, and the forceMigration setting lives in config/database, which Cloud reads like any other environment. The behaviour here was checked against Strapi 5.
Diagnosed everything and still stuck?
S/01 — Performance & Architecture Rescue.
A fixed-scope week that finds the cause and fixes what's causing it.