Habits that make MongoDB migrations safe
I learned most of these on a Friday afternoon when I shipped a bad migration, couldn't roll it back cleanly, and ended up fixing the database by hand. This is the list I wish I'd had before that day.
Most of it applies whatever tool you use. Where a habit maps to a command, I'll show it with mongo-migrate-kit (mmk), the tool I ended up building.
1. Write the down when you write the up
Not "later". Later doesn't come, and the one day you need to roll back is the day you find there's no way back.
And make down undo only what up did. If up sets status: 'active' on users who had no status, down shouldn't remove status from everyone. Mark what you changed, or filter carefully:
export async function up({ db }) {
await db.collection('users').updateMany(
{ status: { $exists: false } },
{ $set: { status: 'active', statusSetByMigration: true } },
);
}
export async function down({ db }) {
await db.collection('users').updateMany(
{ statusSetByMigration: true },
{ $unset: { status: '', statusSetByMigration: '' } },
);
}If a change really can't be undone (you deleted data), make down throw with a clear message. Don't pretend an empty down is a rollback.
2. Then actually run the down
A down that has never run probably doesn't work. Before you commit, run up, then down, then up again on your machine. Better still, have CI do it on every pull request.
3. Be able to roll back one migration
"Undo the most recent one" stops being enough once you ship several migrations together or have more than one environment. You want to undo a specific file, or a specific release:
npx mmk down <file> # one file
npx mmk down --batch <n> # one release
npx mmk down --steps <n> # the last N, whatever the release(There's a whole post on this.)
4. Look before you run
Never run a migration against production without seeing the plan first:
npx mmk dry-run upIt writes nothing and takes a second. Make it a step in your deploy job so it ends up in the log.
5. Lock against two runs at once
Two deploys, a retried CI job, or several app instances booting together can all try to apply the same migration. With a lock, the second one stops. It should be on by default, not something you remember to turn on. (How a good lock handles crashes and long migrations: the locking post.)
6. Notice edits to migrations that already ran
A migration runs in production. Then someone edits the file (a "small fix") and it ships. Now the file doesn't match what changed the database, and environments drift apart without anyone noticing.
Save a checksum of each file when it runs, and compare later. mmk status shows MISMATCH for an edited file, and mmk down refuses to run an edited rollback unless you --force it. The rule for people is simpler: once a migration has run anywhere, it's history. Changes go in a new file.
7. Use transactions for changes with several steps
If a migration makes three writes and dies after the second, the database is neither "before" nor "after". On a replica set or sharded cluster, wrap it in a transaction, so it's all or nothing:
export const useTransaction = true;
export async function up({ db, session }) {
await db.collection('plans').updateMany({}, { $set: { legacy: true } }, { session });
await db.collection('plans').insertOne({ _id: 'pro', price: 29 }, { session });
}Pass session to every call, or that call won't be part of the transaction. mmk also writes its own history record inside the same transaction, so a migration can never be "done but not recorded" and run again next deploy.
8. Work in batches on big collections
updateMany over ten million documents can run for a long time, load your primary, and push your secondaries behind. Loop over _id ranges and update a few thousand at a time. Write it so a second run picks up where the first stopped (filter on documents that still need the change). Big data changes also don't fit in a transaction, which is another reason to make them safe to repeat.
9. Change the schema in steps the running code can live with
During a deploy, old and new versions of your app run side by side for a while. A migration that renames name to fullName breaks whichever version expects the other field.
The safe pattern is expand, then contract:
- Expand: add
fullNamenext toname. Deploy code that writes both and readsfullName, falling back toname. - Backfill: a migration copies
nameintofullNamewhere it's missing. - Contract: once nothing reads
nameany more, a later migration removes it.
Three small migrations over a few deploys, instead of one that has to be perfectly timed.
10. Keep the history, all of it
When you roll a migration back, don't delete its record. Mark it reverted and keep it. "What ran against this database, when, and who ran it" is the first question in an incident. mmk keeps the batch, dates, duration, checksum, environment, who ran it and who wrote it on every record.
11. Run migrations from the pipeline, with config from the environment
Once more than one person ships, "I ran it from my laptop" isn't a process. Run migrations in CI, the same way every time (here's a GitHub Actions setup). Keep connection strings in environment variables or a secret manager, never in the repo.
The short version
Write the down and run it. Roll back one file when one file is wrong. Look before you run. Lock. Notice edits. Use transactions for multi-step changes and batches for big ones. Change schemas in steps. Keep the history. Run it from CI.
I picked up most of these after the Friday that taught me why they matter. You don't have to wait for your own.
Try it
npm install mongo-migrate-kit mongodb
npx mmk dry-run up # the safest possible first commandThe docs start with a five-minute quick start. The package is on npm and GitHub.