Evolving Schemas

Evolving a Schema in Production

MongoDB 1,815 lets you change the shape of documents without a migration, which is a gift and a trap: nothing stops half a collection drifting into a shape no code expects. The safe order never changes. Deploy code that tolerates both shapes first, reading the new field through $ifNull or an application default. Then backfill in bounded batches — updateMany on an indexed filter such as { v: 1 }, a few thousand documents at a time, so no write holds locks or floods the oplog. Only when the backfill reports nothing left do you deploy code that requires the new shape, and only then enforce it with a $jsonSchema validator. validationLevel: 'moderate' applies the rules to inserts and to updates of documents that already pass; strict applies them to everything.

A validator rejecting a bad document, with its real errorPython
db.articles.drop();
db.articles.insertOne({ slug: 'legacy-post', title: 'Written before the rules' });
db.runCommand({ collMod: 'articles', validationLevel: 'moderate', validator: {
  $jsonSchema: { bsonType: 'object', required: ['slug', 'title', 'status', 'createdAt'],
    properties: { slug: { bsonType: 'string', pattern: '^[a-z0-9-]{3,80}$' },
      title: { bsonType: 'string', minLength: 3 }, createdAt: { bsonType: 'date' },
      status: { enum: ['draft', 'review', 'published'] } } } } });
print('moderate: legacy update modified ' + db.articles
  .updateOne({ slug: 'legacy-post' }, { $set: { title: 'Still legal' } }).modifiedCount);
try { db.articles.insertOne({ slug: 'Bad Slug!', title: 'x', status: 'archived',
    createdAt: '2026-09-22' });
} catch (e) { print('rejected with code ' + e.code + ' (' + e.errmsg + ')');
  for (const p of e.errInfo.details.schemaRulesNotSatisfied[0].propertiesNotSatisfied)
    print('  ' + p.propertyName + ': ' + p.details[0].reason + ' -- '
      + JSON.stringify(p.details[0].specifiedAs));
}
Output
moderate: legacy update modified 1
rejected with code 121 (Document failed validation)
  slug: regular expression did not match -- {"pattern":"^[a-z0-9-]{3,80}$"}
  title: specified string length was not satisfied -- {"minLength":3}
  createdAt: type did not match -- {"bsonType":"date"}
  status: value was not found in enum -- {"enum":["draft","review","published"]}

The legacy document is missing status and createdAt, yet its update succeeded — exactly what moderate promises while a backfill is still running. The new document was rejected with error 121, and the server reported every broken rule at once instead of stopping at the first: errInfo.details names the property, the reason and the rule, including createdAt arriving as the string '2026-09-22' where a BSON date was required. That is the most common real defect of all, and no test catches it if the test writes strings too. A second collMod to strict makes the legacy update fail with 121 as well.

Validators are cheap insurance, not a schema language: keep them behind application validation — for Mongoose 243,355 , Built-In and Custom Validation — and treat a 121 in production as a bug in the writer.