"MongoDB is schemaless" is the sentence that causes most of the migration incidents we get called into. The database will happily store whatever shape you send it, but your Mongoose 8 models, your Zod validators, your Angular 22 interfaces and your aggregation pipelines all assume a shape. The moment you rename a field, split a name into firstName/lastName, change a string into an ObjectId reference, or add a required field with no default, you have a schema migration — whether or not you wrote one.
This tutorial shows the process we hand to clients: versioned, reversible migration scripts run by migrate-mongo, applied using the expand/contract pattern so a deploy never requires downtime, with batched backfills that do not melt a production cluster. Versions are current as of 2026: Node.js 24 LTS, Express 5, Mongoose 8, MongoDB 8.0.
This is a companion to our server-version upgrade guide — that one moves MongoDB 6.0 to 8.0, this one changes the documents.
Why ad-hoc mongosh scripts eventually bite
The common pattern is a folder of one-off scripts someone runs by hand against production. It fails for predictable reasons:
- Nobody knows which scripts have already run on which environment.
- There is no down path, so a bad deploy cannot be rolled back.
updateManyover ten million documents holds a single long-running operation and spikes replication lag.- Staging drifts from production, so the next migration is written against a shape that only exists on one server.
- Old application instances are still running during the deploy and keep writing the old shape behind the migration.
Everything below exists to remove one of those five problems.
Step 1: install and configure migrate-mongo
migrate-mongo keeps an ordered set of migration files plus a changelog collection recording what has been applied. It talks to the driver directly, not through Mongoose, which is what you want: migrations must not depend on today's model definitions.
npm install --save-dev migrate-mongo
npx migrate-mongo init
Edit the generated migrate-mongo-config.js to read from the environment so the same code runs in CI, staging and production:
// migrate-mongo-config.js
export default {
mongodb: {
url: process.env.MONGODB_URI,
databaseName: process.env.MONGODB_DB ?? 'app',
options: { serverSelectionTimeoutMS: 10000 },
},
migrationsDir: 'migrations',
changelogCollectionName: 'changelog',
lockCollectionName: 'changelog_lock',
migrationFileExtension: '.js',
useFileHash: true,
moduleSystem: 'esm',
};
Two settings matter more than the rest:
useFileHash: true— migrate-mongo records a hash of each applied file. If someone edits a migration that already ran in production, the next run fails loudly instead of silently skipping it.lockCollectionName— takes a lock so two app instances (or two CI jobs) starting at the same time cannot run the same migration twice.
Add scripts to package.json:
{
"scripts": {
"migrate:status": "migrate-mongo status",
"migrate:up": "migrate-mongo up",
"migrate:down": "migrate-mongo down",
"migrate:create": "migrate-mongo create"
}
}
Step 2: the expand/contract pattern
The reason a field rename causes an outage is that three things change at different times: the documents, the running code, and the old code that has not been shut down yet. During any rolling deploy there is a window where both versions of your API are live at once. So never change a field in place. Split the work into three deploys:
- Expand — add the new field. Application code writes both old and new, and reads the new field with a fallback to the old. Backfill existing documents in the background. Nothing breaks if you roll back, because the old field is still authoritative-compatible.
- Migrate reads — once the backfill is verified complete, deploy code that reads only the new field but still writes both. This is the deploy you can safely revert.
- Contract — deploy code that no longer references the old field, then run a migration that unsets it and drops any stale index.
Our worked example: users.name (a single string) becomes users.firstName and users.lastName.
Expand: the migration
npm run migrate:create -- add-user-first-last-name
// migrations/20260814093000-add-user-first-last-name.js
const BATCH = 1000;
function splitName(name) {
const parts = String(name ?? '').trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return { firstName: '', lastName: '' };
if (parts.length === 1) return { firstName: parts[0], lastName: '' };
return { firstName: parts[0], lastName: parts.slice(1).join(' ') };
}
export async function up(db) {
const users = db.collection('users');
const cursor = users
.find({ name: { $exists: true }, firstName: { $exists: false } })
.project({ name: 1 })
.batchSize(BATCH);
let ops = [];
let migrated = 0;
for await (const doc of cursor) {
const { firstName, lastName } = splitName(doc.name);
ops.push({
updateOne: {
filter: { _id: doc._id },
update: { $set: { firstName, lastName, schemaVersion: 2 } },
},
});
if (ops.length === BATCH) {
await users.bulkWrite(ops, { ordered: false });
migrated += ops.length;
ops = [];
console.log(`migrated ${migrated} users`);
await new Promise((r) => setTimeout(r, 50)); // let replication catch up
}
}
if (ops.length) {
await users.bulkWrite(ops, { ordered: false });
migrated += ops.length;
}
console.log(`done: ${migrated} users migrated`);
}
export async function down(db) {
await db.collection('users').updateMany(
{ firstName: { $exists: true } },
{ $unset: { firstName: '', lastName: '' }, $set: { schemaVersion: 1 } },
);
}
Five things this script does deliberately:
- It is idempotent. The filter excludes documents that already have
firstName, so re-running after a crash resumes instead of redoing work. - It batches with
bulkWrite({ ordered: false })instead of one giantupdateManywith an aggregation expression. Batches are interruptible, observable, and gentle on the oplog. - It projects only the fields it needs, so a 40-field user document does not travel over the wire.
- It sleeps briefly between batches. On a busy replica set this is the difference between a boring migration and a paging alert for replication lag.
- It has a real
down. If you cannot write the down path, you do not yet understand the migration.
Note the deliberate absence of a MongoDB transaction. A multi-million-document backfill inside one transaction will hit the 16 MB oplog entry limit and the 60-second default transaction lifetime. Transactions are for small, related, all-or-nothing writes — see the section below.
Expand: the application side
Dual-write in Mongoose 8 keeps the old field populated for any instance still running the previous release:
// models/user.js
import { Schema, model } from 'mongoose';
const userSchema = new Schema({
name: { type: String }, // legacy, still written during expand
firstName: { type: String, trim: true, default: '' },
lastName: { type: String, trim: true, default: '' },
schemaVersion: { type: Number, default: 2, index: true },
}, { timestamps: true });
// Dual write: derive the legacy field from the new ones.
userSchema.pre('save', function (next) {
if (this.isModified('firstName') || this.isModified('lastName')) {
this.name = [this.firstName, this.lastName].filter(Boolean).join(' ');
}
next();
});
// Read with fallback for documents the backfill has not reached yet.
userSchema.virtual('displayName').get(function () {
if (this.firstName || this.lastName) {
return [this.firstName, this.lastName].filter(Boolean).join(' ');
}
return this.name ?? '';
});
export const User = model('User', userSchema);
Expose displayName to Angular rather than raw fields during the transition, and your front end never sees the migration at all.
Contract: removing the old field
Only after the read-migration deploy has been live long enough that you would not roll back to the dual-read release:
// migrations/20260901101500-drop-user-name.js
export async function up(db) {
const remaining = await db.collection('users').countDocuments({ firstName: { $exists: false } });
if (remaining > 0) {
throw new Error(`refusing to contract: ${remaining} users still lack firstName`);
}
await db.collection('users').updateMany({}, { $unset: { name: '' } });
await db.collection('users').dropIndex('name_1').catch(() => {});
}
export async function down(db) {
// Rebuild the legacy field from the new ones.
await db.collection('users').updateMany({}, [
{ $set: { name: { $trim: { input: { $concat: ['$firstName', ' ', '$lastName'] } } } } },
]);
await db.collection('users').createIndex({ name: 1 });
}
The guard clause at the top is the most valuable line in the file. A contract migration that runs before the backfill finished is how data actually gets lost.
Step 3: schema versioning for documents you cannot backfill
Some collections are too large, too cold, or too expensive to rewrite — event logs, audit trails, analytics. For those, use the document versioning pattern: stamp every document with schemaVersion and migrate lazily on read.
const READERS = {
1: (doc) => ({ ...doc, firstName: (doc.name ?? '').split(' ')[0] ?? '', lastName: (doc.name ?? '').split(' ').slice(1).join(' ') }),
2: (doc) => doc,
};
export function normalizeUser(doc) {
const version = doc.schemaVersion ?? 1;
const reader = READERS[version];
if (!reader) throw new Error(`unknown user schemaVersion ${version}`);
return reader(doc);
}
The cost is a permanent adapter layer, so keep the version window small: two live versions, never five. Track db.users.aggregate([{ $sortByCount: '$schemaVersion' }]) on a dashboard so "we will backfill the rest later" cannot quietly become forever.
Step 4: when to use transactions
MongoDB 8 supports multi-document transactions on replica sets, and they belong in migrations that must move several documents as a unit — for example promoting an embedded subdocument into its own collection:
export async function up(db, client) {
const session = client.startSession();
try {
const orders = db.collection('orders');
const addresses = db.collection('addresses');
const cursor = orders.find({ shippingAddress: { $exists: true }, shippingAddressId: { $exists: false } });
for await (const order of cursor) {
await session.withTransaction(async () => {
const res = await addresses.insertOne({ ...order.shippingAddress, ownerId: order.userId }, { session });
await orders.updateOne(
{ _id: order._id },
{ $set: { shippingAddressId: res.insertedId }, $unset: { shippingAddress: '' } },
{ session },
);
});
}
} finally {
await session.endSession();
}
}
One transaction per document, not one transaction for the whole collection. Keep each under a second, and remember that transactions on a sharded cluster cost noticeably more than on a single replica set.
Step 5: validate at the database, not only in Mongoose
Mongoose validation only protects writes that go through Mongoose. Migration scripts, mongosh sessions, and that one Python analytics job do not. Once a collection is fully migrated, add a JSON Schema validator so the database enforces the invariant too:
export async function up(db) {
await db.command({
collMod: 'users',
validator: {
$jsonSchema: {
bsonType: 'object',
required: ['email', 'firstName', 'schemaVersion'],
properties: {
email: { bsonType: 'string', pattern: '^.+@.+$' },
firstName: { bsonType: 'string' },
lastName: { bsonType: 'string' },
schemaVersion: { bsonType: 'int', minimum: 2 },
},
},
},
validationLevel: 'moderate',
validationAction: 'warn',
});
}
Start with validationAction: 'warn' and watch the logs for a week before switching to error. validationLevel: 'moderate' applies the rules only to documents that already satisfy them plus all new inserts, which is exactly the behaviour you want mid-migration.
Step 6: run migrations in CI/CD, not from a laptop
Migrations belong in the deploy pipeline, as a step that runs before the new application version starts serving traffic — and separate from the app process, so ten container replicas do not each try to migrate.
# .github/workflows/deploy.yml (excerpt)
- name: Migration status (fails on edited files)
run: npm run migrate:status
env:
MONGODB_URI: ${{ secrets.MONGODB_URI }}
- name: Apply migrations
run: npm run migrate:up
env:
MONGODB_URI: ${{ secrets.MONGODB_URI }}
And test the migrations themselves. mongodb-memory-server (or a service container running mongo:8.0) makes up/down round-trips a unit test with Node 24's built-in runner:
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { up, down } from '../migrations/20260814093000-add-user-first-last-name.js';
test('splits legacy name and is reversible', async (t) => {
const users = testDb.collection('users');
await users.insertMany([{ name: 'Ada Lovelace' }, { name: 'Prince' }, { name: ' ' }]);
await up(testDb);
const ada = await users.findOne({ name: 'Ada Lovelace' });
assert.equal(ada.firstName, 'Ada');
assert.equal(ada.lastName, 'Lovelace');
await up(testDb); // idempotency
assert.equal(await users.countDocuments({ firstName: 'Ada' }), 1);
await down(testDb);
assert.equal(await users.countDocuments({ firstName: { $exists: true } }), 0);
});
Running up, then up again, then down in one test catches the two bugs that cause real incidents: non-idempotent backfills and a down path nobody ever executed.
The pre-flight checklist we use
- Take a verified backup or snapshot, and confirm you can restore it — not just that it exists.
- Run the migration against a restored copy of production and record how long it took.
- Estimate document count with
countDocumentson the migration's filter before you start. - Confirm dual-write is deployed and live before the backfill runs.
- Watch replication lag, oplog window and CPU while the backfill runs; tune
BATCHand the sleep, not your luck. - Never edit an applied migration file — write a new one.
- Add index creation as its own migration, and on large collections build indexes rolling, node by node.
- Keep the contract step behind an explicit guard clause and at least one full release cycle of distance.
Where this fits
Expand/contract with versioned scripts is not more work than ad-hoc updates — it is the same work, ordered so that any single step can be rolled back. That is the whole difference between a schema change and an incident.
If you are staring at a collection nobody dares to reshape, or a migration that timed out halfway through last Friday, our MEAN Stack Version Migration and Upgrades and Performance Tuning in MEAN Stack teams do exactly this work. Get in touch with your collection sizes and current document shape and we will tell you what the migration path looks like.