`sync({alter: false})` Is Not a No-Op
Sequelize `sync({alter: false})` is not a no-op — it still locks tables and reads schema metadata on every boot. Here is what it actually does, and what to use instead.
sync({alter: false}) Is Not a No-Op
Mid-cutover dry-run, Sequelize threw a Data too long for column 'referenceId' at row 1 at me. The migration files said the column was VARCHAR(255). The production database said it was VARCHAR(64). Neither file was lying.
What had been happening, quietly, for years: the ORM's "safe" sync call had been ALTERing the schema in development on every boot, and nobody had ever written a migration to make those changes real in production.
What the docs say
From the Sequelize 6 README, paraphrased: sequelize.sync() synchronizes the model definitions to the database. Variants:
sync()— create tables that don't exist; leave existing tables alone.sync({force: true})— drop and recreate every table. Obviously destructive.sync({alter: true})— alter existing tables to match the models. Useful in dev, dangerous in prod.
The natural reading is that sync({alter: false}) (or no argument at all) is safe against existing tables. It is mostly true. It is not entirely true.
What sync() actually does
The Sequelize 6 internals run a routine called _syncModelsWithCyclicReferences. Most of the time this just creates the missing tables in dependency order. But Sequelize is not lossless across sync() calls — the act of registering models, then calling sync, can issue ALTERs that:
- Adjust column types where the model says one thing and the database says another. If your model is
STRING(60)and the column isvarchar(500), sync may attempt to shrink the column. On a fresh database this works because there is no data. On a populated database, the ALTER fails when row 150 has a value 90 characters long. - Re-create foreign key constraints. If the FK has a slightly different ON DELETE behavior in the model than in the database, sync will silently swap it.
- Re-issue index creation. If the database has an index with a slightly different name, sync may add a second one.
The Sequelize team has improved this over the years and many of the explicit ALTERs are gated on alter: true. But the gate isn't airtight in version 6, and the cases that slip through are exactly the cases where your model drifted from your schema.
In a contractor-built codebase, that drift is the default state, not an edge case. The contractor wrote a migration that added a column, then later loosened its length without updating the model — or never updated the model in the first place because "the schema is the source of truth." Your model still says STRING(60). Sequelize still believes it.
The dry-run failure that surfaced this
Concrete example, the one that cost me four hours.
The contractor's migration 032 had widened a column from STRING(60) to varchar(500) in production. The corresponding model definition was never updated — it still said STRING(60). The model was used for reads only, where Sequelize doesn't care if the database has a wider column than the model expects.
During the dry-run, I imported a production dump into a fresh staging instance, then deployed the new application. On first request, the application called sequelize.sync() (the legacy migration-on-first-request pattern from the audit). Sync looked at the model, looked at the database, decided "the column is wider than the model — let me fix that," and issued an ALTER to shrink it. The ALTER hit row 150, which had a 90-character value, and threw Data too long for column 'referenceId'.
This had been running for years against the legacy production database. Why didn't it fail there? Because the legacy database had grown to its current state. Sync had been silently issuing ALTERs that succeeded, because at the time of each ALTER the data fit. The moment I imported a snapshot and ran the same code against it from cold, sync issued its accumulated wishlist of ALTERs as a single set and failed on data that hadn't been there yet during the original drift.
The other failure mode: sync against a freshly-created schema
There's a second mode that is even quieter.
If you run sync() against a fresh database, it creates every table from the model definitions. Now you have a schema. The next thing in your startup sequence is the migration runner, which reads schema_migrations to find pending migrations. The table is empty — no migrations have run — so the runner sees every migration as pending and tries to apply all of them.
Most of those migrations are ALTERs against columns that sync just created. The migrations say things like ALTER TABLE leads ADD COLUMN emailHash VARCHAR(64). Sync already added emailHash. The migration fails with Duplicate column name.
This is the failure mode that bites you the day you stand up a fresh staging environment. Sync creates the schema. Migrations re-add the same columns. Half of them fail loud, half fail silent depending on the SQL dialect.
The fix: gate sync on schema_migrations
Here's the rule I now use, and it has held up across three environments and one production cutover:
const [results] = await sequelize.query(
"SHOW TABLES LIKE 'schema_migrations'"
);
const tableExists = results.length > 0;
let shouldSync = !tableExists;
if (tableExists) {
const [rows] = await sequelize.query(
'SELECT COUNT(*) AS n FROM schema_migrations'
);
shouldSync = rows[0].n === 0;
}
if (shouldSync) {
await sequelize.sync({ alter: false });
await migrationManager.markAllKnownAsApplied(Object.keys(migrations));
}
await migrationManager.runMigrations();
The logic: if schema_migrations exists and has rows, the database has prior migration history. Skip sync entirely. Run only the genuinely-pending migrations. If schema_migrations doesn't exist or is empty, the database is fresh. Run sync to create the schema, then bulk-insert every known migration name into schema_migrations so the migration runner sees them as already-applied.
What you're trading off
The trade-off is real: once you stop running sync against production, your models and your schema have to match. Sync is no longer doing the silent reconciliation. If you add a column to a model and forget to write a migration, that column won't exist in the database and your application will throw on the first query that references it.
That's fine. That's what you want. The whole point of migrations is that schema changes are explicit, reviewed, and reproducible.
The migration runner is your source of truth. sync() was a convenience that was costing you more than it saved.
What I tell teams to do
- Wrap
sync()in aschema_migrations-aware gate. The snippet above is a starting point. - Treat migration files as append-only. Modifying an existing migration's body is meaningless against any database that already recorded the migration as applied.
- Run migrations as a separate deploy step, not on the first HTTP request. Bad migrations should fail the deploy, not the request.
Next in the series: the four-minute cutover that took fourteen days to plan — and the dry-run that caught the silent-corruption case the night before.
Run the same audit on your own stack. Open the 30-question checklist →
Next in the series: Migrating Production Data: A 4-Minute Cutover That Took 14 Days to Plan →
Run the audit on your own stack
A 30-question self-audit. P0/P1/P2 severity. Takes about an hour.
Open the checklist →