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:
- Backward-compatible additions. A new optional field is introduced but left out of the
requiredarray. 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. - 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. - Breaking changes. Changing a field’s
bsonType, removing arequiredfield, 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.
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
collModdoes 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$norcount 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.
collModis not transactional. Schema modifications are metadata operations that execute immediately and cannot participate in a multi-document transaction. Never nest acollModinside an application transaction scope; run it standalone withwriteConcern: "majority"so the metadata change survives a failover.- Version-field enum drift. If you pin
schema_versionwith anenumin 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
$jsonSchemadocuments raise validation cost per write; keep conditional logic shallow and preferbsonTypechecks over sprawlingpatternregexes on hot-path collections. dateModifiedvs. data reality. Bumping a document’s stampedschema_versionin 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.
Related
- MongoDB JSON Schema Validation Architecture — the parent reference that defines the enforcement contract these versioning strategies deploy against.
- Automating schema linting in CI/CD pipelines — gate every version change with static checks, offline sampling, and cluster dry-runs before it reaches production.
- Strict vs moderate validation levels — the enforcement dial that decides how legacy documents behave during a versioned rollout.
- Understanding MongoDB
$jsonSchemasyntax — the operator surface for authoring the per-version validators, including theschema_versionenum pin. - Building fallback validation chains — where documents rejected during a strict promotion are quarantined for asynchronous reconciliation.