Available — booking into Q4 2026 UK · Remote BST · GMT+1
All fixes
F/04 10 min read reviewed Sep 2026· Strapi 5

Changing a Strapi schema without losing data

TL;DR

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

bars = how often this is the answer

Cause 01 of 02

Renaming a field drops the old column

Symptom

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.

The change

In the schema it's a one-word edit, the attribute key going from summary to excerpt:

json
// before
"summary": { "type": "text" }

// after
"excerpt": { "type": "text" }
What Strapi sees

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:

sql
SELECT id, summary FROM articles LIMIT 5;
-- ERROR:  column "summary" does not exist
Why it happens

Strapi 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.

The fix

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.

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:

config/database.ts ts
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:

src/api/product/content-types/product/schema.json json
"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:

database/migrations/2026.09.16T00.00.00.backfill-price-cents.js js
'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:

text
[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 |      990000

One 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.

— Strapi documentation, Database migrations

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

Diagnosed everything and still stuck?

S/01 — Performance & Architecture Rescue.

A fixed-scope week that finds the cause and fixes what's causing it.