How to Enforce Strict Validation on Existing Collections
Retrofitting validationLevel: "strict" onto a production collection that predates its schema is the exact operational question this page answers, sitting under strict vs moderate validation levels within the broader MongoDB JSON Schema Validation Architecture. The reader outcome is a repeatable, zero-downtime cutover: you finish with a strict + error validator attached, every historical document compliant, and a single reversible command ready if the rollout misbehaves. The trap that makes this hard is grandfathering — legacy documents that violate the new $jsonSchema persist untouched until an application next writes to them, at which point the write fails with code 121. The goal is to reach 100% compliance before you flip enforcement, without lock contention, oplog bloat, or an application-level write storm.
Operational Mechanics and Write-Path Impact
MongoDB evaluates a validator exclusively on the synchronous write path. Setting validationLevel: "strict" tells the storage engine to check every insert, update, replace, and findAndModify against the schema regardless of document age, while moderate only checks inserts and updates to documents that are already valid — leaving non-compliant historical documents invisible to the engine until they are modified. The validationAction dial is orthogonal: it decides whether a failing write is rejected or merely logged. The four combinations behave as follows:
validationAction |
validationLevel |
Documents checked | On failure |
|---|---|---|---|
warn |
moderate |
Inserts + updates to already-valid docs | Logs, write succeeds |
warn |
strict |
All inserts and updates | Logs, write succeeds |
error |
moderate |
Inserts + updates to already-valid docs | Rejects with WriteError 121 |
error |
strict |
All inserts and updates | Rejects with WriteError 121 |
The strict + warn row is the pivot of the entire migration: it applies the schema to every write for telemetry while letting non-compliant writes through, so you can measure the true blast radius before rejecting anything. The precise semantics of the warn-versus-error choice, and why it belongs in a separate rollout stage, are covered under setting up validationAction warn vs error in production. Attaching or changing a validator is done with collMod, which is a metadata-only operation: it takes a short-lived exclusive collection lock, writes an oplog entry, and never rebuilds documents — so the enforcement flip itself costs milliseconds, not a full-collection rewrite.
Exact Diagnostic Fingerprints and Fast Resolution
Before enforcing, inventory the exact scope of non-compliance. Because $jsonSchema is also a valid query operator, wrapping it in $nor counts documents that would fail the target schema with no validator active and no data mutation:
// Count documents that WOULD fail the target schema (dry run, no writes).
db.target_collection.countDocuments({
$nor: [{ $jsonSchema: {
bsonType: "object",
required: ["tenant_id", "status"],
properties: {
tenant_id: { bsonType: "string" },
status: { bsonType: "string", enum: ["provisioned", "suspended"] }
}
}}]
})
// Expected output: an integer — the exact remediation backlog, e.g. 12843
Once the schema is live in strict + warn, each non-compliant write emits a diagnostic to the mongod / mongos log under message id 51803, carrying the _id and the failing rule. These are the signatures to match during and after the cutover:
| Signature | Where it appears | Root cause | Resolution |
|---|---|---|---|
Log id 51803, "Document would fail validation" |
mongod/mongos log while in warn |
Legacy document violates the new schema | Feed the _id into the remediation loop below |
code: 121, "Document failed validation" |
Driver / client, after switch to error |
An application write hit a still-non-compliant doc | Inspect errInfo.details.schemaRulesNotSatisfied; patch or route the doc |
pymongo.errors.WriteError (single) / BulkWriteError (batch) |
PyMongo automation | Same as 121, surfaced through the driver | Read exc.details["errInfo"] for the failing JSON path |
The schemaRulesNotSatisfied structure (MongoDB 5.0+) tells you which keyword and path failed, so remediation can be targeted rather than guessed. Rather than tailing raw logs during a live rollout, wire the 51803 signal into validation-failure tracking with MongoDB Atlas alerts so the remaining backlog is a dashboard number instead of a grep.
Step-by-Step Playbook
The safe sequence isolates every validation failure from production write paths until the backlog is zero. Documents that cannot be normalized in place should be diverted through fallback routing for invalid documents rather than force-patched.
1. Deploy the schema in strict + warn. The validator now applies to every write but rejects nothing, so violations are logged while production keeps flowing:
db.runCommand({
collMod: "target_collection",
validator: { $jsonSchema: {
bsonType: "object",
required: ["tenant_id", "status"],
properties: {
tenant_id: { bsonType: "string" },
status: { bsonType: "string", enum: ["provisioned", "suspended"] }
}
}},
validationLevel: "strict",
validationAction: "warn"
})
// Expected output: { ok: 1 }
2. Run background remediation. Iterate the non-compliant inventory with server-side filtering, normalize legacy fields, and apply idempotent $set updates in unordered batches so one bad document never halts the batch. The production-safe loop:
from pymongo import MongoClient, UpdateOne, errors
import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
client = MongoClient("mongodb://primary:27017")
collection = client.production_db.target_collection
# Same schema, negated, as a server-side filter for the remediation backlog.
violation_filter = {
"$nor": [{
"$jsonSchema": {
"bsonType": "object",
"required": ["tenant_id", "status"],
"properties": {
"tenant_id": {"bsonType": "string"},
"status": {"bsonType": "string",
"enum": ["provisioned", "suspended"]},
},
}
}]
}
def remediate(batch_size: int = 1000) -> int:
patched = 0
ops: list = []
projection = {"_id": 1, "tenant_id": 1, "status": 1}
for doc in collection.find(violation_filter, projection).batch_size(batch_size):
updates = {}
if not isinstance(doc.get("tenant_id"), str):
updates["tenant_id"] = "unknown_legacy"
if doc.get("status") not in ("provisioned", "suspended"):
updates["status"] = "provisioned"
if updates:
ops.append(UpdateOne({"_id": doc["_id"]}, {"$set": updates}))
if len(ops) >= batch_size:
patched += _flush(ops)
ops = []
if ops:
patched += _flush(ops)
return patched
def _flush(ops: list) -> int:
try:
# ordered=False keeps one invalid doc from stopping the batch.
return collection.bulk_write(ops, ordered=False).modified_count
except errors.BulkWriteError as bwe:
logger.warning("partial batch failure: %s", bwe.details["writeErrors"])
return bwe.details.get("nModified", 0)
if __name__ == "__main__":
logger.info("remediated %d documents", remediate())
For hardening this loop with retry and logging conventions your platform already uses, see the PyMongo validation wrapper scripts pattern.
3. Verify the backlog is zero. Re-run the dry-run count from the previous section. It must return 0 before you proceed — this is the go/no-go gate for enforcement:
db.target_collection.countDocuments({ $nor: [{ $jsonSchema: /* target schema */ {} }] })
// Expected output: 0
4. Switch to error. With the backlog cleared, flip enforcement. The transition is instantaneous and metadata-only — no collection rebuild:
db.runCommand({
collMod: "target_collection",
validationLevel: "strict",
validationAction: "error"
})
// Expected output: { ok: 1 }
Failure Modes & Rollback
Each stage has a distinct way to go wrong, and each has a bounded time-to-recover:
- Error code 121 after the switch (step 4). Means a document slipped through remediation and an application write hit it. If 121s exceed ~0.1% of write volume, roll back immediately — do not try to patch under load.
- Oplog window pressure (step 2). Large-batch remediation floods the oplog; a slow secondary can fall off the window and require a full resync. Watch the oplog window and throttle
batch_sizeso replication keeps pace before you ever reach step 4. - Write-latency spikes (steps 1 & 4). Validation adds CPU to every write. If P95 write latency rises more than ~15%, throttle the remediation worker or widen
bulk_writebatches to cut round-trips.
The rollback is a single metadata-only collMod that restores write availability without touching data. Dropping to warn keeps the schema attached for telemetry; the harder revert removes enforcement entirely:
// Soft rollback: stop rejecting, keep the schema for logging.
db.runCommand({
collMod: "target_collection",
validationLevel: "moderate",
validationAction: "warn"
})
// Hard rollback: detach the validator completely.
db.runCommand({ collMod: "target_collection", validator: {}, validationLevel: "off" })
Both take effect on the primary immediately and propagate through the oplog, so time-to-recover is the replication lag of your slowest secondary — typically seconds. Once the application stabilizes, resume remediation and re-attempt step 4. Treating the schema itself as a versioned artifact through schema versioning strategies for NoSQL makes each of these cutovers auditable rather than ad hoc.
Frequently Asked Questions
What happens to in-flight writes during the collMod switch to error?
collMod takes a short exclusive collection lock, so concurrent writes queue behind it for the duration (milliseconds for a metadata change) and then execute against the new validator. There is no window where some writes see the old action and others the new one — the change is atomic. In-flight writes that were already committed are unaffected; grandfathering never re-validates them.
Does switching to strict rewrite or re-scan existing documents?
No. validationLevel only governs which future writes are checked. It never walks the collection or touches stored documents, which is why the flip is instantaneous even on billion-document collections. Historical compliance must be achieved by your remediation pass beforehand — the $nor + $jsonSchema count is how you prove it reached zero.
Can I skip the warn stage and enforce strict + error directly?
Only if the dry-run count already returns 0. If any non-compliant document remains, going straight to error means the next application update to that document fails with code 121 in production, with no telemetry to tell you how many are lurking. The warn stage exists to convert that unknown into a measured backlog before it becomes a user-facing error.
Related
- Strict vs Moderate Validation Levels — the parent guide to how
validationLeveldecides which documents get checked. - MongoDB JSON Schema Validation Architecture — the overarching enforcement architecture this migration fits inside.
- Setting up validationAction warn vs error in production — the enforcement dial you toggle in steps 1 and 4, in depth.
- Fallback routing for invalid documents — where to divert documents that cannot be normalized in place.
- Tracking validation failures with MongoDB Atlas alerts — turn the
51803warn signal into a live backlog dashboard.
For authoritative operator behavior and the full collMod argument surface, consult the official MongoDB schema-validation documentation.