A Mongoose schema change is only half the job: existing MongoDB documents must also be moved safely to the new shape. This guide provides a reusable Mongoose data migration checklist, including versioned Node.js scripts, dry runs, batching, backups, rollback decisions, and practical examples for renaming fields, backfilling values, and changing document structures.
Overview
A MongoDB schema migration changes persisted data so it remains compatible with application code after a release. Although MongoDB permits documents in the same collection to have different fields, mixed shapes can make queries, validation, indexes, and application behavior harder to reason about. A migration turns an intended schema evolution into a controlled DevOps workflow.
Keep migrations separate from application startup code. A deployment should not silently scan or rewrite a production collection merely because a process restarted. Instead, store each migration as a versioned script, run it deliberately in a controlled environment, record whether it succeeded, and make the operation observable.
A practical migration workflow is:
- Describe the old and new document shapes and define success criteria.
- Inspect representative data, including documents with missing, null, malformed, or unexpected values.
- Write a versioned migration that is safe to test and, where possible, safe to rerun.
- Run it against a fixture database or staging copy, then perform a dry run against production-like data.
- Create and verify a backup or another recovery path before changing production data.
- Run in bounded batches, monitor progress, validate the result, and record the outcome.
- Deploy application code that supports the new shape, including any temporary compatibility logic.
For related validation and fixture practices, see the Mongoose testing guide. If a migration must update several related documents atomically, review the Mongoose transactions tutorial and confirm that the workload is appropriate for a transaction.
Checklist by scenario
Renaming a field
For a rename such as displayName to name, first decide whether the application will temporarily read both fields. A compatibility period can reduce deployment risk: write the new field, backfill old documents, deploy readers that prefer the new field, and remove the old field only after all relevant code has stopped using it.
const filter = { displayName: { $exists: true }, name: { $exists: false } };
const update = [{ $set: { name: '$displayName' } }];
await User.updateMany(filter, update);Use a filter that avoids overwriting a value already written to name. Before removing displayName, check application code, reporting jobs, exports, indexes, and operational scripts for references.
Backfilling a value
For a new field such as status, define the source of truth and the fallback behavior. A default in the Mongoose schema affects newly created or validated documents; it does not automatically populate every existing document in MongoDB. A backfill may use a constant, another field, or a transformation.
const filter = { status: { $exists: false }, archived: { $ne: true } };
const update = { $set: { status: 'active' } };
const result = await User.updateMany(filter, update);
console.log({ matched: result.matchedCount, modified: result.modifiedCount });Do not assume that every missing value should receive the same default. Inspect edge cases first, and make the filter narrow enough to exclude records that require manual review.
Changing a document shape
When converting a scalar into an object, or moving nested data, write the transformation explicitly. For example, a legacy address string might become an object containing line1, city, and postalCode. Values that cannot be parsed reliably should be quarantined or reported rather than silently discarded.
const cursor = User.find({ address: { $type: 'string' } }).lean().cursor();
for await (const user of cursor) {
const parsed = parseAddress(user.address);
if (!parsed) {
console.warn('manual review required', { id: String(user._id) });
continue;
}
await User.updateOne(
{ _id: user._id, address: user.address },
{ $set: { address: parsed }, $unset: { legacyAddress: '' } }
);
}The extra condition on the update helps avoid replacing a value that another process changed after the document was read. For larger collections, use bounded batches and measure each batch rather than loading the full result set into memory.
Choosing a rollback approach
| Migration type | Possible recovery | Important limitation |
|---|---|---|
| Copy or add a field | Unset the new field or rerun from the original field | Later application writes may make reversal ambiguous |
| Rename with the old value retained | Copy the value back, then remove the new field | Conflicts must be detected before restoration |
| Destructive transformation | Restore from backup or use an audit copy | An inverse script may not recover lost information |
| Multi-document update | Use a transaction when suitable, or restore affected records | Transactions do not replace a backup and have operational constraints |
Prefer additive migrations when possible. Keeping the original value until the new representation has been verified gives the team more recovery options.
What to double-check
- Query scope: Test the filter with a count and sample documents. Confirm whether missing, null, empty, and incorrectly typed values should be included.
- Idempotency: Running the script twice should either produce the same result or stop safely. Use existence checks, stable predicates, and conditional updates.
- Batching: Avoid an unbounded
find().toArray()for a large collection. Use a cursor or explicit batches, and define a batch size that your environment can handle. - Concurrency: Decide what happens if the application writes the same documents during the migration. A compare-and-set predicate, maintenance window, or temporary dual-write strategy may be appropriate.
- Indexes: Check whether the migration filter is supported by an existing index. Also plan any new index separately; index builds can have their own resource and deployment considerations.
- Validation: Compare counts before and after, sample transformed documents, check for remaining legacy shapes, and exercise important reads and writes.
- Observability: Log the migration version, start and end times, batches, matched and modified counts, skipped records, and errors without exposing sensitive document contents.
- Deployment order: Ensure the application can tolerate the intermediate state. A common sequence is expand, migrate, verify, then contract by removing legacy fields or compatibility code.
Keep the migration's connection and model setup explicit. The script should use the same relevant schema definitions and connection configuration as the application, but it should not depend on an HTTP server being started. Review connection-pool and query behavior using the Mongoose performance checklist before running a large backfill.
Common mistakes
Editing an old migration instead of adding a new one. Once a migration has run in an environment, changing its contents can make the recorded history disagree with reality. Add a corrective migration and explain why it exists.
Relying only on schema defaults. Defaults help with new documents and some create or validation paths, but they are not a general-purpose existing-data backfill. Query and update the stored documents deliberately.
Using an unrestricted update. An incorrect filter can modify records that were already migrated or should have been excluded. Test the filter independently and include a before-and-after sample.
Assuming rollback means running the opposite update. A transformation can lose information, and concurrent writes can make an inverse operation unsafe. Preserve original values when practical and document the recovery plan before execution.
Ignoring partial completion. A process can stop after several batches. Record progress, make reruns safe, and verify both completed and untouched records. Do not mark a migration complete solely because the process exited without an exception.
Coupling migration success to application availability. Large backfills can compete with normal traffic. Run them as controlled operational jobs, set an acceptable pace, and pause if latency, errors, or resource usage move outside agreed limits.
When to revisit
Revisit this checklist before every schema release that changes persisted data, before a production backfill, and during seasonal planning cycles when dormant records or historical collections may be brought into active use. Also review it when workflows, Mongoose or MongoDB dependencies, deployment tooling, backup procedures, or collection indexes change.
Before execution, ask the owner to confirm four items: the exact filter, the expected affected-record count, the recovery method, and the validation query. After execution, attach the migration version, logs, counts, verification results, and any skipped-record report to the deployment record.
For a final operational sequence, use this short checklist:
- Open a pull request containing the migration, tests, and a brief rollback plan.
- Run it against fixtures and a staging or restored copy.
- Perform a production dry run that reports candidates without writing.
- Verify backup availability and confirm who can stop or restore the job.
- Run in batches, monitor application health, and pause on unexpected results.
- Validate the new shape and confirm that legacy records are either migrated or intentionally queued for review.
- Only then remove compatibility fields, old indexes, or legacy code in a later, separately reviewed change.
Used this way, a Mongoose migration becomes a repeatable DevOps control rather than an improvised database edit. Keep the checklist with the repository, update examples when your deployment process changes, and treat each schema release as a small, observable data operation.