Strict vs Moderate Validation Levels in MongoDB
Within the broader MongoDB JSON Schema Validation Architecture, the choice between validationLevel: "strict" and validationLevel: "moderate" is the single dial that decides how an active $jsonSchema validator treats documents that already exist in a collection. It is not a syntax preference — it is an enforcement contract with the storage engine that governs write-path latency, migration safety, and whether a legacy payload can still be written after the schema tightens. This guide is a complete implementation workflow for platform engineers and Python automation builders: it pins down the exact runtime semantics of each level, gives you a prerequisites checklist, an idempotent deployment sequence, a production-ready PyMongo manager with retry and drift detection, the precise diagnostic fingerprints a misconfiguration produces, and the verification and rollback commands to operate the change safely. The deliverable is a level transition you can run against a live replica set without a write outage.
Architectural Context & Enforcement Boundaries
MongoDB evaluates a collection validator at the storage-engine layer on the write path only — during insert, update, replace, and findAndModify. Reads never touch it, and aggregation $out/$merge bypass it on the destination. The validator is the innermost synchronous gate described in implementing collection-level validators, and validationLevel decides which writes that gate inspects. The behavioural divergence between the two levels centres entirely on documents that currently violate the schema:
strictenforces the schema on everyinsertand everyupdate, regardless of the target document’s current compliance state. If an existing document is missing arequiredfield or carries a wrongbsonType, any write that touches it is rejected withWriteErrorcode121(Document failed validation) — even when the write itself does not introduce the violation.moderateenforces the schema on all inserts, and on updates only when the target document already satisfies the validator. An update to a document that currently fails validation bypasses the check entirely, provided the modification itself introduces no new violation. This grandfathers legacy payloads so they can be normalised incrementally without a write storm.
The evaluation pipeline short-circuits on the first failed keyword, so schema ordering and the precision of your required arrays and pattern constraints — covered under understanding MongoDB $jsonSchema syntax — directly determine which writes strict rejects. Documents that are rejected should never be dropped; route them through fallback routing for invalid documents so a 121 becomes a remediation task rather than lost data.
validationLevel never acts alone: it is paired with validationAction, which decides whether a failed check rejects the write or merely logs it. The two dials form a four-cell matrix that fully specifies enforcement behaviour.
validationAction |
validationLevel |
Writes checked | On failure |
|---|---|---|---|
error |
strict |
All inserts and updates | Reject with WriteError 121 |
error |
moderate |
Inserts + updates to already-valid docs | Reject with WriteError 121 |
warn |
strict |
All inserts and updates | Log to mongod diagnostics; write succeeds |
warn |
moderate |
Inserts + updates to already-valid docs | Log to mongod diagnostics; write succeeds |
strict suits greenfield collections, financial ledgers, and compliance-bound workloads where silent corruption is unacceptable. moderate suits high-throughput event streams and collections mid-refactor, where a short window of grandfathered non-compliance is an acceptable trade for uninterrupted writes.
Prerequisites & Operational Requirements
Confirm every item below before changing a validation level on a live collection.
- MongoDB version: 5.0 or later. All server versions implement JSON Schema Draft 4 semantics, and the rich
errInfoobject used in the diagnostics section (the structuredschemaRulesNotSatisfieddetail) requires 5.0+. On 4.x the failure reason is an opaque message. - Topology: a replica set, not a standalone.
collModis metadata-only, but its effect must propagate through the oplog to secondaries; on a standalone you lose the failover safety the rollback procedure depends on. - Driver: PyMongo 4.x (
pip install "pymongo>=4.6,<5"). Pin it in your automation image soOperationFailure.code,errInfo, andbulk_writesemantics stay stable across builds. - Permissions: changing a validator or its level requires the
collModaction, granted by thedbAdminrole on the database. Reading current options vialistCollectionsrequireslistCollections. Keep the automation principal least-privilege; a level change must never needclusterAdmin. - Baseline audit: before promoting to
strict, you must know the non-compliant document count. Compute it with$jsonSchemaas a query operator (no validator need be active):db.coll.countDocuments({ $nor: [{ $jsonSchema: <schema> }] }). Do not usedb.coll.validate()for this — that command checks BSON and index integrity, not$jsonSchemacompliance.
Idempotent Deployment / Implementation Workflow
Transitioning a populated collection from moderate to strict is a deterministic sequence. Each step is independently verifiable and safe to re-run.
-
Quantify drift. Count the documents that would fail the target schema so you know the remediation scope:
db.orders.countDocuments({ $nor: [{ $jsonSchema: { bsonType: "object", required: ["tenant_id", "status"], properties: { tenant_id: { bsonType: "string" }, status: { bsonType: "string", enum: ["open", "settled", "void"] } } }}] }) -
Attach the validator in observe mode. Apply the schema with
validationLevel: "strict"andvalidationAction: "warn". Every non-compliant write is now logged tomongoddiagnostics (log id20294) without being rejected, giving you a live failure feed with zero write impact:db.runCommand({ collMod: "orders", validator: { $jsonSchema: { bsonType: "object", required: ["tenant_id", "status"], properties: { tenant_id: { bsonType: "string" }, status: { bsonType: "string", enum: ["open", "settled", "void"] } } } }, validationLevel: "strict", validationAction: "warn" }) -
Normalise legacy documents. Run a batched, idempotent remediation job over the drift inventory. Because the level is still permissive, updates to non-compliant documents proceed even if a batch is interrupted and retried. Version the schema you are converging on alongside your schema versioning strategies for NoSQL so the target contract is auditable.
-
Re-verify drift is zero. Re-run the step 1 count. Only proceed when it returns
0. -
Promote to enforcement. Switch
validationActionto"error". The transition is atomic, metadata-only, and requires no collection rebuild. Detailed handling of the grandfathering mechanics for a collection that still holds legacy documents at this point is documented in how to enforce strict validation on existing collections.db.runCommand({ collMod: "orders", validationAction: "error" })
Production-Ready Automation Implementation
The following PyMongo module applies a validation level idempotently: it reads the live collection options, skips the collMod entirely when the target state already matches (so repeated CI/CD runs never take a redundant lock), classifies non-retryable failures, and retries transient cluster-state conflicts with bounded exponential backoff.
import logging
import random
import time
from typing import Any, Dict, Optional
from pymongo.collection import Collection
from pymongo.errors import OperationFailure, PyMongoError
logger = logging.getLogger(__name__)
# collMod / auth failures that must never be retried.
NON_RETRYABLE_CODES = {13, 2, 26} # Unauthorized, BadValue, NamespaceNotFound
class SchemaValidationManager:
"""Idempotently manage a collection's validator, validationLevel and validationAction."""
def __init__(self, collection: Collection, max_retries: int = 3, base_delay: float = 0.5):
self.collection = collection
self.max_retries = max_retries
self.base_delay = base_delay
def _current_options(self) -> Optional[Dict[str, Any]]:
"""Read live validator/level/action from listCollections (not collStats)."""
info = self.collection.database.command(
"listCollections", filter={"name": self.collection.name}
)
batch = info["cursor"]["firstBatch"]
return batch[0].get("options") if batch else None
def count_violations(self, schema: Dict[str, Any]) -> int:
"""Count documents that would fail the schema, using $jsonSchema as a query."""
return self.collection.count_documents({"$nor": [{"$jsonSchema": schema}]})
def apply_validation(
self,
schema: Dict[str, Any],
level: str = "strict",
action: str = "error",
) -> bool:
"""Apply the target validator/level/action. Return True if changed, False if already current."""
if level not in ("strict", "moderate", "off"):
raise ValueError(f"Invalid validationLevel: {level!r}")
if action not in ("error", "warn"):
raise ValueError(f"Invalid validationAction: {action!r}")
target_validator = {"$jsonSchema": schema}
current = self._current_options() or {}
if (
current.get("validationLevel") == level
and current.get("validationAction") == action
and current.get("validator") == target_validator
):
logger.info("Validation config already at target state; no collMod issued.")
return False
# Guardrail: never promote straight to strict+error while drift remains.
if level == "strict" and action == "error":
violations = self.count_violations(schema)
if violations:
raise RuntimeError(
f"Refusing strict/error promotion: {violations} non-compliant documents remain. "
"Deploy strict/warn and remediate first."
)
for attempt in range(1, self.max_retries + 1):
try:
self.collection.database.command(
"collMod",
self.collection.name,
validator=target_validator,
validationLevel=level,
validationAction=action,
)
logger.info("Applied validationLevel=%s action=%s (attempt %d)", level, action, attempt)
return True
except OperationFailure as exc:
if exc.code in NON_RETRYABLE_CODES:
logger.error("Non-recoverable collMod failure (code %s): %s", exc.code, exc)
raise
if attempt == self.max_retries:
logger.error("collMod failed after %d attempts: %s", self.max_retries, exc)
raise
delay = self.base_delay * (2 ** (attempt - 1)) + random.uniform(0, 0.2)
logger.warning("Transient collMod failure %d/%d; retry in %.2fs",
attempt, self.max_retries, delay)
time.sleep(delay)
except PyMongoError as exc:
logger.error("Unexpected driver error during collMod: %s", exc)
raise
return False
The guardrail in apply_validation encodes the rule that makes the whole workflow safe: the manager refuses to move a collection to strict/error while any document would fail, forcing the observe-and-remediate path. Wire this class into your infrastructure-as-code pipeline so the level is declared, not manually toggled.
Diagnostic Fingerprints & Fast Resolution
A level misconfiguration surfaces through a small set of exact signatures. Match on these to route the incident immediately.
| Signature | Root cause | Resolution |
|---|---|---|
pymongo.errors.WriteError code 121 (Document failed validation) after promoting to strict |
An update touched a grandfathered legacy document that was never normalised. | Inspect exc.details["errInfo"]["details"]["schemaRulesNotSatisfied"]; remediate the document or roll back to moderate. |
| Rejection rate spikes immediately at cutover, not gradually | Drift count was non-zero at promotion — step 4 was skipped. | Roll back to warn+moderate, re-run the drift count, remediate, retry. |
pymongo.errors.OperationFailure code 13 (Unauthorized) on collMod |
Automation principal lacks the collMod action. |
Grant dbAdmin on the target database to the service principal. |
OperationFailure code 9 (FailedToParse) / unknown keyword on collMod |
Schema uses a keyword MongoDB’s Draft 4 engine rejects (if/then, $defs, format). |
Rewrite with Draft 4 combinators; validate on a throwaway collection first. |
Writes to non-compliant docs succeed unexpectedly under what you believe is strict |
Collection is actually on moderate, or validationAction is warn. |
Confirm live state with the diagnostic below. |
Read the live enforcement state straight from the collection catalog — this is the ground truth, not what your deploy script intended:
db.getCollectionInfos({ name: "orders" })[0].options
// => { validator: {...}, validationLevel: "strict", validationAction: "error" }
To pull the structured reason a specific write was rejected under strict, catch the driver exception and inspect errInfo:
from pymongo.errors import WriteError
try:
db.orders.update_one({"_id": doc_id}, {"$set": {"note": "x"}})
except WriteError as exc:
rules = exc.details["errInfo"]["details"]["schemaRulesNotSatisfied"]
print("Failed keywords:", rules)
Edge Cases, Gotchas & Known Limitations
moderateis not “validate only new documents”. It validates every insert and every update to an already-valid document. A common misread is expecting existing valid documents to be exempt — they are not; only currently-invalid documents are grandfathered on update.- A no-op update still triggers strict validation. Under
strict, even an$setthat writes an identical value re-validates the whole document. A background job that re-touches every row will surface every latent violation at once — throttle it and watch the121rate. replace_oneis validated as an insert-shaped write. A full replacement undermoderateis checked even against a currently-invalid document, because the result is a brand-new document image. Partial$setupdates are what the grandfathering rule protects.- Changing only the level does not re-scan existing data.
collModis metadata-only and instantaneous regardless of collection size; it never walks the collection. Compliance of historical documents is only ever tested lazily, on their next write. validationAction: "warn"in production must be time-boxed. It is a migration instrument, not a steady state — a permanently-warning collection silently accumulates malformed documents. Set an alert on how long a collection has been inwarn.- Aggregation
$merge/$outbypass the validator entirely on the destination collection, at any level. A pipeline that rewrites documents can reintroduce non-compliant data that a subsequentstrictupdate will then reject.
Verification & Rollback Procedures
Confirm the change landed and is enforcing before you rely on it. A positive test (a known-bad write must be rejected) is the only proof that strict/error is genuinely active:
from pymongo.errors import WriteError
# After promotion, a write that violates the schema MUST raise.
try:
db.orders.insert_one({"tenant_id": 123, "status": "open"}) # tenant_id wrong bsonType
raise AssertionError("strict validation is NOT enforcing")
except WriteError as exc:
assert exc.code == 121
print("strict/error confirmed: malformed insert rejected with 121.")
Rollback is a single atomic, metadata-only command that restores write availability within milliseconds and loses no data. A soft rollback keeps the schema attached but stops rejecting; a hard rollback detaches it entirely:
// Soft rollback — keep the contract, stop blocking writes (time-to-recover: one collMod).
db.runCommand({ collMod: "orders", validationLevel: "moderate", validationAction: "warn" })
// Hard rollback — remove enforcement completely for emergency recovery.
db.runCommand({ collMod: "orders", validator: {}, validationLevel: "off" })
Both take effect on the primary immediately and propagate through the oplog to secondaries. Treat the soft rollback as your default incident response: it re-opens the write path while preserving the failure telemetry you need to finish remediation.
Frequently Asked Questions
Does changing validationLevel with collMod lock or rebuild the collection?
No. A level or action change is a metadata-only operation that completes in milliseconds regardless of collection size — it never walks or rewrites documents. It takes a short intent lock on the collection's catalog entry, not an exclusive lock over the data, so in-flight reads and writes are not blocked. Existing documents are only ever re-checked lazily, on their next write.
Under moderate, will an update to a legacy document ever fail validation?
Only if the document is currently valid, or if your update turns a valid document invalid. If the target document already fails the schema, a partial $set update bypasses validation entirely. Note that a full replace_one is treated as a new document image and is validated even against a currently-invalid document.
Should I ever go straight from no validator to strict + error?
Not on a populated collection. Any non-compliant document becomes a latent WriteError 121 that fires the next time an application touches it, producing a rejection spike that looks like an outage. Attach the validator as strict + warn first, remediate the drift to zero, then promote the action to error.
How do I read a collection's live validation level rather than what my script set?
Run db.getCollectionInfos({ name: "coll" })[0].options (or the driver equivalent via listCollections). The validationLevel, validationAction, and validator fields returned are the ground truth held in the collection catalog — not the intent of any deploy script.
Does validationLevel affect read queries or aggregation output?
No. Validators run on the write path only — insert, update, replace, and findAndModify. Reads are never checked, and aggregation $out/$merge bypass the destination collection's validator at any level, which is a common way non-compliant data slips back in.
Related
- MongoDB JSON Schema Validation Architecture — the parent reference defining the enforcement contract and write-path evaluation pipeline these levels operate within.
- How to enforce strict validation on existing collections — the zero-downtime migration playbook for promoting a populated collection to
strict. - Understanding MongoDB
$jsonSchemasyntax — the keyword surface whose precision determines which writesstrictrejects. - Schema versioning strategies for NoSQL — versioning the schema contract you converge legacy documents onto before promotion.
- Fallback routing for invalid documents — where a rejected
121write goes so tightening a level never means data loss. - Implementing collection-level validators — attaching and operating the validator that
validationLeveltunes.