Skip to content
ExponaBLOG
MONGODB, NOSQL & DATA MODELING · DAY 055 / 100

Migrating schemas in MongoDB: what developers should know

Schemas change even without migrations.

Expona Admin9 min read

Introduction

Schemas in MongoDB change constantly—new fields arrive, old ones get renamed, data structures evolve to match business needs. Unlike SQL databases where schema changes require explicit migrations before deployment, MongoDB lets you add and modify fields on the fly, which creates a false sense of freedom. The real challenge arrives when you have millions of documents spread across versions of your application, some with old shapes, some with new ones, and you need to coordinate updates without downtime. Understanding migrating schemas in MongoDB: what developers should know means learning how to handle these transitions gracefully, safely, and with confidence in production.

Why schema migrations matter in MongoDB

MongoDB's flexible schema is both a feature and a trap. You can deploy code that writes new fields without migrating existing data, but over time your collections become heterogeneous—some documents have the old structure, some have the new one. Application code must then handle both formats, introducing defensive programming patterns and subtle bugs.

Consider a user collection where you rename emailAddress to email. Your new code writes to email, but millions of old documents still have emailAddress. For months, your queries check both fields, your update logic branches to handle either case, and your team carries mental overhead. A deliberate migration strategy eliminates this friction.

Schema version field — Store a version on each document

The simplest and most effective approach is to add a schemaVersion field to every document. This single integer tells you which version of the schema that document was last written under. When your code reads a document, it immediately knows whether it needs to transform the data in memory.

Start by choosing an initial version number. Many teams use 1 as the baseline:

javascript
// Initial schema (version 1)
db.users.insertOne({
  _id: ObjectId(),
  name: "Alice",
  emailAddress: "alice@example.com",
  schemaVersion: 1,
  createdAt: new Date()
});

When you decide to rename emailAddress to email, bump the version in your application code:

javascript
// In your service layer, when saving a user
async function saveUser(user) {
  const doc = {
    ...user,
    schemaVersion: 2,
    updatedAt: new Date()
  };
  return await usersCollection.updateOne(
    { _id: user._id },
    { $set: doc }
  );
}

On read, check the version and transform as needed:

javascript
// In your data access layer
async function getUser(userId) {
  const doc = await usersCollection.findOne({ _id: userId });
  
  if (doc.schemaVersion === 1) {
    // Transform old format to new format
    doc.email = doc.emailAddress;
    delete doc.emailAddress;
  }
  
  return doc;
}

This approach is lightweight and works well for gradual transitions. The drawback: your code must know about every schema version, and after several years with many versions, the transformation logic becomes complex. It's best paired with one of the other strategies to eventually retire old versions.

Lazy migration — Upgrade documents when they are read or written

Lazy migration takes the schema version concept further by automatically upgrading documents whenever they're accessed. The idea is that old documents remain in the database until touched, at which point they're transformed to the current schema version.

A practical implementation uses middleware or a repository pattern to intercept reads and writes:

javascript
// Migration functions mapping version to upgrade logic
const migrations = {
  1: (doc) => {
    // Version 1 to 2: rename emailAddress to email
    if (doc.emailAddress && !doc.email) {
      doc.email = doc.emailAddress;
      delete doc.emailAddress;
    }
    return doc;
  },
  2: (doc) => {
    // Version 2 to 3: convert phone string to object
    if (typeof doc.phone === 'string') {
      doc.phone = { raw: doc.phone, country: null };
    }
    return doc;
  }
};

const CURRENT_VERSION = 3;

function migrateDocument(doc) {
  if (!doc || !doc.schemaVersion) return doc;
  
  let version = doc.schemaVersion;
  while (version < CURRENT_VERSION) {
    if (migrations[version]) {
      doc = migrations[version](doc);
    }
    version++;
  }
  
  doc.schemaVersion = CURRENT_VERSION;
  return doc;
}

// Wrapper around find
async function findUser(query) {
  const doc = await usersCollection.findOne(query);
  return migrateDocument(doc);
}

// Wrapper around update
async function updateUser(query, updates) {
  const doc = await usersCollection.findOne(query);
  const migrated = migrateDocument(doc);
  
  return await usersCollection.updateOne(
    { _id: migrated._id },
    { $set: { ...migrated, ...updates, schemaVersion: CURRENT_VERSION } }
  );
}

Lazy migration shines when you have infrequent access to old documents. A dormant user account won't be upgraded until someone logs in. This spreads CPU and I/O load over time. The trade-off is that your application must always run both read and write paths through the migration wrapper, adding latency to every query.

One risk: if a document isn't accessed for a year, it remains in the old schema. If you ever need to query on a newly-structured field, old documents won't match. You must pair lazy migration with monitoring to ensure documents are progressing through versions.

Batch migration — Background scripts update documents in chunks

Batch migration is the most predictable approach. A scheduled background job or one-time script processes documents in chunks, transforming them to the new schema. This gives you full control over timing, progress, and rollback.

A typical pattern:

javascript
// migrations/002-rename-email-address.js
async function migrateUp(db) {
  const collection = db.collection('users');
  const batchSize = 1000;
  let processed = 0;
  
  while (true) {
    const batch = await collection
      .find({ schemaVersion: { $lt: 2 } })
      .limit(batchSize)
      .toArray();
    
    if (batch.length === 0) break;
    
    const updates = batch.map(doc => ({
      updateOne: {
        filter: { _id: doc._id },
        update: {
          $set: {
            email: doc.emailAddress,
            schemaVersion: 2,
            migratedAt: new Date()
          },
          $unset: { emailAddress: "" }
        }
      }
    }));
    
    await collection.bulkWrite(updates);
    processed += batch.length;
    console.log(`Migrated ${processed} documents`);
  }
}

async function migrateDown(db) {
  const collection = db.collection('users');
  // Reverse the operation if needed
  await collection.updateMany(
    { schemaVersion: 2 },
    {
      $rename: { email: "emailAddress" },
      $set: { schemaVersion: 1 }
    }
  );
}

module.exports = { migrateUp, migrateDown };

Run this during a maintenance window or as a background cron job. Monitor progress in logs and set alerts if the script fails. The upside: all documents end up in the new schema within a predictable timeframe, and application code doesn't need version-checking logic. The downside: you need to schedule and coordinate the migration, and your database might experience higher load during the batch operation.

Use bulkWrite to group operations and reduce round-trips to MongoDB. Always set reasonable batch sizes—1,000 to 5,000 documents per batch is typical—to avoid locking collections or consuming too much memory.

Migration tools — migrate-mongo tracks scripts like SQL migrations

As your application grows, managing migrations manually becomes error-prone. Tools like migrate-mongo bring SQL-style versioning to MongoDB. They track which migrations have run, store state, and provide a CLI to execute pending migrations.

Set up migrate-mongo in your Node.js project:

javascript
// npm install migrate-mongo

// In .migrate/config.js
module.exports = {
  mongodb: {
    url: process.env.MONGODB_URI,
    databaseName: "myapp"
  },
  migrationsDir: ".migrations",
  changelogCollectionName: "migrations"
};

Create a migration file:

javascript
// .migrations/20240115_001_rename_email_field.js
module.exports = {
  async up(db) {
    const collection = db.collection('users');
    
    await collection.updateMany(
      { emailAddress: { $exists: true } },
      [
        {
          $set: {
            email: "$emailAddress",
            schemaVersion: 2
          }
        },
        { $unset: "emailAddress" }
      ]
    );
  },
  
  async down(db) {
    const collection = db.collection('users');
    
    await collection.updateMany(
      { schemaVersion: 2 },
      [
        {
          $set: {
            emailAddress: "$email",
            schemaVersion: 1
          }
        },
        { $unset: "email" }
      ]
    );
  }
};

Run migrations in your deployment pipeline:

javascript
// In your deploy script or CI/CD
npx migrate-mongo up

migrate-mongo records which migrations have run in a special collection, preventing duplicate execution and giving you a clear audit trail. This approach scales well and integrates smoothly with deployment automation. The downside is adding another tool to your stack, but the clarity and safety it provides is worth it for any production system with regular schema changes.

Common mistakes

  • Forgetting to handle both old and new formats in queries. After a migration, old documents may still exist in the database. If you write a query that only checks for the new field, those old documents won't match. Always query on both fields or ensure all documents are migrated before cleaning up logic.
  • Running batch migrations without testing on a backup first. A migration script bug can corrupt data across millions of documents. Always test on a copy of production data, and run with a small batch limit before scaling up.
  • Mixing migration strategies without a clear plan. Combining lazy migration with periodic batch jobs can leave documents in inconsistent versions. Pick one strategy per schema change and stick with it.
  • Ignoring backward compatibility during reads. If you deploy code that writes the new schema but old documents are still in the database, your reads must handle both formats. Don't assume all documents are up-to-date.
  • Not monitoring migration progress. A long-running batch job can fail halfway through, leaving some documents migrated and others not. Always add logging and alerts.

Key takeaways

  • Use a schemaVersion field on every document to track which version of the schema it follows, enabling safe transformations and gradual rollouts.
  • Lazy migration is ideal for large collections with infrequent access patterns; it spreads the cost of transformation over time as documents are read or written.
  • Batch migration gives you the most control and predictability; use it for critical data or when you need all documents updated by a deadline.
  • Migration tools like migrate-mongo bring SQL-style version tracking to MongoDB; they prevent duplicate runs and give you a clear audit trail of schema changes.
  • Always handle both old and new schema formats in your application code during a transition period, and test migrations on production data backups before running them live.
ShareXLinkedInReddit

Found this useful? Support the writing.

Buy me a coffee
Written by
Expona Admin
Site administrator

Comments

Loading comments…

Leave a comment

Comments are reviewed before they appear. Be kind and stay on topic.

Related articles