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:

  • strict enforces the schema on every insert and every update, regardless of the target document’s current compliance state. If an existing document is missing a required field or carries a wrong bsonType, any write that touches it is rejected with WriteError code 121 (Document failed validation) — even when the write itself does not introduce the violation.
  • moderate enforces 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.

How validationLevel routes a write on the MongoDB write path Every insert or update reaches the validationLevel decision. Under strict, the write is validated against the full $jsonSchema regardless of the target document's current state. Under moderate, MongoDB first asks whether the target document is already compliant: if yes, the write is validated; if no, validation is bypassed and the grandfathered legacy document is written unchecked. A validated write is tested for a valid result — a valid result and a bypassed write both succeed, while an invalid result is rejected with WriteError code 121. strict moderate yes no valid invalid insert / update validationLevel? Target doc compliant? Validate write against full schema Result valid? Bypass validation grandfathered legacy doc Write succeeds Reject write WriteError 121 Write-path enforcement · strict checks every write, moderate grandfathers currently-invalid docs on update

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 errInfo object used in the diagnostics section (the structured schemaRulesNotSatisfied detail) requires 5.0+. On 4.x the failure reason is an opaque message.
  • Topology: a replica set, not a standalone. collMod is 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 so OperationFailure.code, errInfo, and bulk_write semantics stay stable across builds.
  • Permissions: changing a validator or its level requires the collMod action, granted by the dbAdmin role on the database. Reading current options via listCollections requires listCollections. Keep the automation principal least-privilege; a level change must never need clusterAdmin.
  • Baseline audit: before promoting to strict, you must know the non-compliant document count. Compute it with $jsonSchema as a query operator (no validator need be active): db.coll.countDocuments({ $nor: [{ $jsonSchema: <schema> }] }). Do not use db.coll.validate() for this — that command checks BSON and index integrity, not $jsonSchema compliance.

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.

  1. 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"] }
        }
      }}]
    })
  2. Attach the validator in observe mode. Apply the schema with validationLevel: "strict" and validationAction: "warn". Every non-compliant write is now logged to mongod diagnostics (log id 20294) 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"
    })
  3. 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.

  4. Re-verify drift is zero. Re-run the step 1 count. Only proceed when it returns 0.

  5. Promote to enforcement. Switch validationAction to "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" })
The moderate-to-strict promotion timeline Five ordered stops: 1 quantify drift by counting violations; 2 attach the validator as strict plus warn; 3 normalise legacy documents with a batched job; 4 re-verify that drift equals zero; 5 promote the action to strict plus error. From stop 2 through stop 5 the validationAction is warn, so writes are never blocked — non-compliant writes are only logged. A dashed rollback arc runs from stop 5 back to stop 2, labelled soft rollback to moderate plus warn, showing that enforcement can be reverted in one metadata-only command. Enforcement begins only at stop 5. soft rollback → moderate + warn (one collMod) validationAction: warn — writes never blocked until step 5 1 Quantify drift count violations 2 Attach validator strict + warn 3 Normalise docs batched job 4 Re-verify drift = 0 5 Promote strict + error Enforcement begins only at step 5 — the write path stays live throughout the remediation window

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

  • moderate is 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 $set that 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 the 121 rate.
  • replace_one is validated as an insert-shaped write. A full replacement under moderate is checked even against a currently-invalid document, because the result is a brand-new document image. Partial $set updates are what the grandfathering rule protects.
  • Changing only the level does not re-scan existing data. collMod is 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 in warn.
  • Aggregation $merge/$out bypass the validator entirely on the destination collection, at any level. A pipeline that rewrites documents can reintroduce non-compliant data that a subsequent strict update 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.