Schema Versioning Strategies for NoSQL

Within the broader MongoDB JSON Schema Validation Architecture, schema versioning is the discipline that keeps a flexible document model from decaying into silent, unmanaged drift. NoSQL databases like MongoDB trade rigid DDL for a permissive write path, but production systems still evolve their document shapes constantly — new fields appear, types tighten, required sets change — and without explicit version identifiers those changes collide across services that deploy at different times. This page defines a concrete versioning contract for MongoDB collections: how to stamp documents with a version, how to classify each change as backward-compatible, forward-compatible, or breaking, and how to roll new $jsonSchema rules out idempotently with a runnable Python deployment path, exact error diagnostics, and a rollback sequence you can execute during an incident.

Architectural Context & Enforcement Boundaries

Schema versioning sits at the governance layer of the enforcement pipeline. The collection-level validators do the synchronous work of rejecting a bad write, but they only know about one schema at a time — the rule currently stored in options.validator. Versioning is what lets that single active rule change safely while heterogeneous clients, old documents, and in-flight migrations all coexist. It answers a question the validator itself cannot: which contract does any given document claim to satisfy, and is the transition between contracts safe to apply right now.

The mechanism is a schema_version integer stamped at the document root. Every write records the contract version it was written under, so consumers can branch parsing logic deterministically instead of guessing from the presence or absence of fields. When you author the validation rules that read and enforce this field, the operator semantics come from Understanding MongoDB $jsonSchema Syntax — conditional required arrays, enum pins on the version field, and bsonType constraints that differ per version. The version stamp and the validator are two halves of one contract: the stamp declares intent, the validator enforces it.

Versioned changes fall into three operational categories, and the category dictates the deployment path:

  1. Backward-compatible additions. A new optional field is introduced but left out of the required array. Older application versions ignore the unknown field; newer versions populate it. No migration is required, and the version number may bump on write without any coordinated cutover.
  2. Forward-compatible deprecations. A legacy field stays optional but is explicitly ignored by updated application logic. The validator marks it deprecated with $comment, giving a sunset window during which both old and new readers behave correctly before the field is finally removed.
  3. Breaking changes. Changing a field’s bsonType, removing a required field, or restructuring nested documents cannot be absorbed silently. These require a coordinated migration window, a dual-schema tolerance period, and a rollback plan. Platform teams must treat them as infrastructure events, not code edits.
How a versioned schema change is classified and rolled out Every schema change is triaged into one of three categories. Adding an optional field left out of the required array is backward-compatible. Deprecating a field with a sunset window is forward-compatible. Changing a field's bsonType, removing a required field, or restructuring documents is a breaking change that needs a migration window and rollback plan. All three categories are first deployed at validationLevel moderate so legacy documents are not re-validated, then promoted to validationLevel strict once every writer runs the new code and the non-compliant count reaches zero. Classify the change Roll out, then promote Schema change Change type? Backward-compatible new optional field · not in required no migration · bump on write Forward-compatible field deprecated via $comment sunset window · both readers safe Breaking change bsonType / required / restructure migration window + rollback validationLevel moderate legacy docs not re-validated validationLevel strict promote after count = 0 add optional field deprecate type / required change converged
Every versioned change is triaged into one of three categories, rolled out at moderate so legacy documents are grandfathered, and promoted to strict only once every writer has converged.

The two advisory categories converge on validationLevel: "moderate" during rollout and only promote to strict after every writer runs the new code. That promotion path is the safety mechanism explored in depth in Strict vs Moderate Validation Levels; versioning decides when the promotion is safe, the validation level decides how the engine treats non-compliant legacy documents while you wait.

Prerequisites & Operational Requirements

Before applying versioned schema changes to a live collection, confirm the following environment assumptions. The deployment pattern below fails or behaves surprisingly if any of these are unmet.

Requirement Minimum Why it matters
MongoDB server 5.0+ The rich errInfo validation-failure object (schemaRulesNotSatisfied) that drives diagnostics is only populated on 5.0 and later. collMod on earlier versions returns opaque errors.
Deployment topology Replica set collMod is a metadata write that must reach a majority to be durable; a standalone node cannot honor writeConcern: "majority".
PyMongo driver pymongo>=4.6,<5 Pin the major version. The Collection.options() helper and command result shapes referenced here are stable across 4.x.
Role / privilege collMod action on the namespace Requires dbAdmin or a custom role granting collMod; a plain read/write user gets error code 13.
Change type reviewed classified as one of the three categories Applying a breaking change with strict/error on a non-migrated collection is the most common self-inflicted outage.

Pin the driver explicitly in requirements.txt (pymongo==4.10.1) so a transitive upgrade cannot change command-result parsing under you. Run the deployment from a host with a stable connection to the primary — a mid-collMod network partition is handled by the retry logic below, but repeated flapping will exhaust retries.

Idempotent Deployment / Implementation Workflow

Treat schema deployment as infrastructure-as-code: the same script run twice must not double-apply, and it must be safe to re-run after a partial failure. The workflow is a fixed sequence of verifiable steps.

Step 1 — Read the live validator. Fetch the collection’s current options before touching anything. collection.options() is a PyMongo convenience method that internally issues listCollections and returns the options dict, including the active validator, validationLevel, and validationAction. It is the correct way to read the live rule without parsing listCollections by hand.

current = collection.options()
active_validator = current.get("validator", {})
active_level = current.get("validationLevel", "strict")

Step 2 — Diff intent against reality. Compare the target schema and level against what is live. If they already match, the deployment is a no-op — skip it so you never take an unnecessary exclusive lock on the namespace.

import json
already_current = (
    json.dumps(active_validator, sort_keys=True) == json.dumps(target_schema, sort_keys=True)
    and active_level == target_level
)

Step 3 — Choose the validation level for the change category. For any backward- or forward-compatible change, apply with validationLevel: "moderate" first so legacy documents are not re-validated on update. For a greenfield collection with no legacy data, strict is safe immediately.

Step 4 — Apply with collMod. Issue the metadata change. This is instantaneous and does not scan existing documents.

// mongosh equivalent of the deploy step
db.runCommand({
  collMod: "orders",
  validator: { $jsonSchema: { /* target schema */ } },
  validationLevel: "moderate",
  validationAction: "error"
})

Step 5 — Converge legacy data, then promote. Run the migration or normalization job that brings existing documents up to the new version, then re-run the deployment targeting validationLevel: "strict". Because Step 2 diffs first, this second run only changes the level, not the whole validator.

Each step is independently verifiable: after Step 4, collection.options() must echo your target validator; after Step 5, a count_documents({"$nor": [{"$jsonSchema": target}]}) must return 0.

Production-Ready Automation Implementation

The following module packages the workflow into a single idempotent function with explicit error classification, structured logging, and bounded retries for transient cluster state. It applies a versioned validator only when the live rule differs, and it maps MongoDB error codes to actionable operator messages rather than surfacing a raw stack trace.

import json
import logging
import random
import time
from typing import Any, Dict

from pymongo import MongoClient
from pymongo.errors import OperationFailure, PyMongoError

logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
logger = logging.getLogger(__name__)


def deploy_schema_version(
    client: MongoClient,
    db_name: str,
    coll_name: str,
    target_schema: Dict[str, Any],
    validation_level: str = "moderate",
    max_retries: int = 3,
) -> bool:
    """
    Idempotently apply a versioned $jsonSchema validator to a collection.

    Returns True if the validator/level was applied or updated, False if the
    live configuration already matched the target (no-op).

    Raises OperationFailure for non-transient failures (privilege, validation
    conflict) after classifying the error code.
    """
    db = client[db_name]
    coll = db[coll_name]

    # Step 1 + 2: read the live validator and diff against intent.
    current_opts = coll.options()
    current_validator = current_opts.get("validator", {})
    current_level = current_opts.get("validationLevel", "strict")

    if (
        json.dumps(current_validator, sort_keys=True) == json.dumps(target_schema, sort_keys=True)
        and current_level == validation_level
    ):
        logger.info("Validator already current on %s.%s — skipping.", db_name, coll_name)
        return False

    # Step 4: apply with bounded retry for transient lock/failover conditions.
    for attempt in range(1, max_retries + 1):
        try:
            db.command(
                "collMod",
                coll_name,
                validator=target_schema,
                validationLevel=validation_level,
                validationAction="error",
                writeConcern={"w": "majority"},
            )
            logger.info(
                "Applied schema to %s.%s at level '%s' (attempt %d).",
                db_name, coll_name, validation_level, attempt,
            )
            return True

        except OperationFailure as exc:
            if exc.code == 121:
                logger.error(
                    "collMod rejected: existing documents violate the new rule. "
                    "Migrate first, then retry at 'moderate'."
                )
                raise
            if exc.code == 13:
                logger.error("Unauthorized: the deploy role lacks the collMod action on %s.", coll_name)
                raise
            if exc.code in (11600, 91, 189, 262):  # interrupted / shutdown / not-primary / exceeded-time
                delay = (2 ** (attempt - 1)) * 0.5 + random.uniform(0, 0.25)
                logger.warning("Transient failure (code %s); retrying in %.2fs.", exc.code, delay)
                time.sleep(delay)
                continue
            logger.error("Unhandled OperationFailure (code %s): %s", exc.code, exc)
            raise

        except PyMongoError as exc:
            logger.error("Driver-level error during deploy: %s", exc)
            raise

    logger.error("Exhausted %d retries applying schema to %s.%s.", max_retries, db_name, coll_name)
    raise RuntimeError(f"collMod did not converge after {max_retries} attempts")

Documents that slip past the write path — bulk ETL imports, manual mongosh edits — still need somewhere to go when a later strict promotion rejects them. Wire the deployment into the building fallback validation chains pattern so non-compliant records are quarantined into a dedicated invalid_documents collection for asynchronous reconciliation rather than blocking the pipeline.

Diagnostic Fingerprints & Fast Resolution

When a versioned deployment fails, the error code tells you exactly which guardrail tripped. Match the fingerprint, apply the resolution.

Fingerprint Exception / code Root cause Fast resolution
Document failed validation on a subsequent write WriteError / OperationFailure code 121 A legacy document at an older schema_version was updated under strict, or a breaking change was promoted before migration finished. Drop back to validationLevel: "moderate", run the migration, then re-promote.
not authorized on db to execute command { collMod ... } OperationFailure code 13 Deploy role lacks the collMod action. Grant dbAdmin or a custom role with collMod on the namespace.
Unknown modifier / keyword rejected at collMod OperationFailure The schema uses a keyword MongoDB does not implement (format, if/then/else, $ref). Rewrite in supported Draft 4 keywords per JSON Schema Draft 4 vs Draft 2019 in MongoDB.
timed out waiting for lock OperationFailure collMod metadata lock contended under peak write load. Retry (handled above) or schedule during a low-traffic window.

To find how many existing documents a proposed version would reject before you deploy — the single most useful pre-flight check — count non-compliant documents with $nor + $jsonSchema, which needs no active validator:

// How many live documents would fail the target schema?
db.orders.countDocuments({ $nor: [ { $jsonSchema: targetSchema } ] })

When a strict promotion does reject a write, inspect the structured failure object to see the exact rule that failed rather than guessing:

// Surface the precise unsatisfied rule from a captured WriteError
db.runCommand({ insert: "orders", documents: [ badDoc ], ordered: true }).writeErrors[0].errInfo.details

The schemaRulesNotSatisfied array inside errInfo.details names the offending property and the constraint it violated — feed that field name straight into your migration script.

Edge Cases, Gotchas & Known Limitations

  • collMod does not re-validate existing data. Applying a stricter validator never touches documents already on disk; they are grandfathered until their next write. A collection can therefore report a “strict” validator while silently holding thousands of non-compliant documents. Always pair a strict promotion with the $nor count above.
  • Indexes do not follow the schema. Adding a required field or a new nested path does not create an index for it. Schema changes and index builds are independent operations — coordinate them, or queries against the new field will collection-scan.
  • collMod is not transactional. Schema modifications are metadata operations that execute immediately and cannot participate in a multi-document transaction. Never nest a collMod inside an application transaction scope; run it standalone with writeConcern: "majority" so the metadata change survives a failover.
  • Version-field enum drift. If you pin schema_version with an enum in the validator, every new version must be added to that enum before any writer emits it — otherwise the writer that bumps the version is rejected by the rule meant to accept it. Widen the enum in a backward-compatible deploy first.
  • Deep nesting and schema size. Extremely deep or large $jsonSchema documents raise validation cost per write; keep conditional logic shallow and prefer bsonType checks over sprawling pattern regexes on hot-path collections.
  • dateModified vs. data reality. Bumping a document’s stamped schema_version in code without an accompanying migration produces documents that claim a version they do not structurally satisfy. The stamp is a promise; only migrate-then-stamp keeps it honest.

Verification & Rollback Procedures

Confirm the change applied. After deployment, read the live options back and assert convergence:

// Verify the active validator and level match intent
db.getCollectionInfos({ name: "orders" })[0].options
// Confirm zero non-compliant documents before promoting to strict
db.orders.countDocuments({ $nor: [ { $jsonSchema: targetSchema } ] })  // expect 0

A green verification is both facts together: the options echo your target validator and the non-compliant count is zero.

Rollback. A versioned schema change is metadata-only, so reversion is fast and takes effect on the primary immediately, propagating through the oplog.

  • Soft rollback (stop rejecting, keep the rule): flip the action to warn so writes succeed and violations are only logged.

    db.runCommand({ collMod: "orders", validationAction: "warn" })
  • Level rollback (re-admit legacy documents): drop from strict back to moderate so updates to non-compliant documents stop failing.

    db.runCommand({ collMod: "orders", validationLevel: "moderate" })
  • Hard rollback (remove enforcement entirely): clear the validator. Use only as a last resort — it removes the contract for every writer.

    db.runCommand({ collMod: "orders", validator: {}, validationLevel: "off" })

Time-to-recover for any of these is a single command round-trip plus oplog propagation — typically sub-second on a healthy replica set. Because deploy_schema_version diffs before applying, re-running it after a rollback re-converges the collection to the intended version without manual cleanup.

Frequently Asked Questions

Does applying a stricter $jsonSchema with collMod re-validate existing documents?

No. collMod is a metadata operation — it swaps the active validator instantly and never scans data already on disk. Existing documents are grandfathered and only fail validation the next time they are written. To measure how many would fail today, run countDocuments({ $nor: [ { $jsonSchema: targetSchema } ] }) before promoting to strict.

Where should the schema_version field live, and what type should it be?

Stamp it at the document root as an integer (bsonType: "int"), not inside a nested metadata object, so it is cheap to index and query. A monotonically increasing integer keeps comparison logic trivial; reserve semantic major.minor.patch strings for the schema artifacts in your repository, not the per-document stamp.

Should a versioned deploy start at strict or moderate?

Start at moderate for any collection that already holds data, so updates to legacy documents are not rejected mid-rollout, then promote to strict once every writer runs the new code and the non-compliant count reaches zero. Only a greenfield collection with no legacy documents is safe to deploy at strict immediately.

Why was the write that bumped schema_version itself rejected?

If the validator pins schema_version with an enum, the new version must be added to that enum in a backward-compatible deploy before any client emits it. Otherwise the rule intended to accept the new version rejects the very first write that uses it. Widen the enum first, then roll out the writers.

Can I run collMod inside an application transaction?

No. Schema modifications are metadata operations and cannot participate in multi-document transactions. Run the collMod standalone with writeConcern: "majority" and keep it outside any application transaction scope, or the driver will error and leave the metadata state ambiguous.