Automating Schema Linting in CI/CD Pipelines for MongoDB
Enforcing document structure in a schemaless database means catching bad $jsonSchema definitions in the deployment pipeline, before collMod ever runs against production. This page is a runnable playbook for that gate: it sits inside the schema versioning strategies for NoSQL workflow, which itself belongs to the broader MongoDB JSON Schema Validation Architecture that governs how constraints are parsed and enforced at the storage layer. By the end you will have a three-phase lint stage — static Draft 4 meta-validation, offline document sampling, and a live cluster dry-run that counts non-compliant documents — plus the exact error signatures, driver exceptions, and rollback commands to operate it. The audience is MongoDB developers, data engineers, Python automation builders, and platform teams who need schema changes to ship with zero downtime and no silent data corruption.
When validation rules are treated as runtime afterthoughts instead of version-controlled artifacts, teams hit three recurring failure modes: writes that fail long after deploy, forced rollbacks, and query-plan degradation on malformed fields. A linting stage removes the guesswork by proving compatibility offline and against a staging cluster before promotion.
Operational Mechanics and Write-Path Impact
MongoDB’s validation engine runs at the storage layer, not the application layer. That single fact is why an offline linter is necessary but not sufficient: a schema that passes Python’s jsonschema can still be rejected at collMod time (unsupported keyword) or leave existing documents non-compliant (data-state drift). A production-grade lint stage therefore executes three independent phases, escalating from cheap-and-local to authoritative-and-remote.
The two enforcement dials the pipeline sets on promotion are validationAction and validationLevel. Their combination determines which documents are checked and what happens on failure — the pipeline should never jump straight to the strictest pair. The matrix below is the decision surface the promotion step encodes:
validationAction |
validationLevel |
Documents checked | On violation | Pipeline use |
|---|---|---|---|---|
warn |
moderate |
Inserts + updates to already-valid docs | Logged (id 51803), write succeeds |
First rollout stage; observe drift |
warn |
strict |
Every insert and update | Logged, write succeeds | Full-surface drift audit, no rejection |
error |
moderate |
Inserts + updates to already-valid docs | Rejected, code 121 |
Enforce new writes, grandfather legacy docs |
error |
strict |
Every insert and update | Rejected, code 121 |
Final enforced state after convergence |
Because $jsonSchema is also a valid query operator, the pipeline can measure the real blast radius of a change without touching the collection’s configuration: counting documents that match {"$nor": [{"$jsonSchema": schema}]} returns exactly the set that would be rejected under strict. That is the authoritative Phase 3 signal, and it requires only find permission.
Exact Diagnostic Fingerprints and Fast Resolution
When a schema promotion fails, the pipeline log and the pymongo.errors.OperationFailure payload carry precise signatures. Match them fast rather than guessing.
| Error signature | Root cause | Fast resolution |
|---|---|---|
OperationFailure: Document failed validation (code 121) |
A write during or after collMod violates the new $jsonSchema; legacy docs are grandfathered under moderate but rejected under strict on their next write |
Apply validationLevel: "moderate" first, run a background normalization job, then re-run collMod to reach strict |
OperationFailure: $jsonSchema keyword 'format' is not currently supported |
MongoDB implements Draft 4 with extensions; format, if/then/else, $ref, and unevaluatedProperties are rejected outright, not ignored |
Strip unsupported keywords in a pre-commit transform; replace format with a pattern regex |
OperationFailure: timed out waiting for lock / WriteConcernError (code 64) |
collMod takes an exclusive metadata lock; contention on a hot or large collection stalls it |
Schedule promotion in a low-traffic window; keep moderate while normalization runs in parallel |
The keyword-support trap is the one that most often slips past CI, because offline linters default to a later draft. MongoDB pins server-side $jsonSchema to Draft 4 — the divergence is covered in depth under JSON Schema Draft 4 vs Draft 2019 in MongoDB. Pin your validator accordingly and cross-check the MongoDB schema validation keyword matrix for your server version. This copy-paste snippet reproduces the offline half of the gate and surfaces both failure classes locally:
import json
from jsonschema import Draft4Validator
from jsonschema.exceptions import SchemaError
# Keywords MongoDB's Draft 4 $jsonSchema rejects at collMod time.
UNSUPPORTED = {"format", "if", "then", "else", "$ref", "$schema",
"unevaluatedProperties", "unevaluatedItems"}
def walk_keys(node):
"""Yield every object key anywhere in a nested schema document."""
if isinstance(node, dict):
for key, value in node.items():
yield key
yield from walk_keys(value)
elif isinstance(node, list):
for item in node:
yield from walk_keys(item)
with open("orders.schema.json") as f:
schema = json.load(f)
# Meta-validation: is the schema itself a valid Draft 4 document?
try:
Draft4Validator.check_schema(schema)
print("Schema structure: OK (Draft 4)")
except SchemaError as exc:
raise SystemExit(f"BLOCK: schema violates Draft 4 spec -> {exc.message}")
# Unsupported-keyword scan: catch keywords the server rejects, not ignores.
found = UNSUPPORTED.intersection(walk_keys(schema))
if found:
raise SystemExit(f"BLOCK: MongoDB-unsupported keywords present -> {sorted(found)}")
The Draft4Validator.check_schema call is meta-validation — it checks the schema definition, not any document. The API is documented in the python-jsonschema reference.
Step-by-Step Playbook
The following stage drops into GitHub Actions or GitLab CI as a single blocking job. It runs the three phases in order and exits non-zero on the first hard failure, so a broken schema never reaches the promotion step.
-
Meta-validate and scan the artifact (Phase 1). Load the committed schema and run
Draft4Validator.check_schemaplus the unsupported-keyword scan shown above. This is deterministic and needs no database — fail the build immediately on any hit. -
Sample staging documents offline (Phase 2). Pull a statistically meaningful sample and validate it against the proposed schema in-process, off the critical path of any cluster:
import pymongo from jsonschema import Draft4Validator client = pymongo.MongoClient("mongodb://staging:27017", serverSelectionTimeoutMS=5000) sample = list(client.shop.orders.aggregate([{"$sample": {"size": 10000}}])) validator = Draft4Validator(schema) violations = [d.get("_id") for d in sample if not validator.is_valid(d)] if violations: raise SystemExit(f"BLOCK: {len(violations)} sampled docs fail schema " f"(first ids: {violations[:5]})") -
Run the live cluster dry-run (Phase 3). Ask the staging cluster for the exact rejection rate using
$nor+$jsonSchema. NocollMod, no config change, no write:coll = client.shop.orders total = coll.estimated_document_count() non_compliant = coll.count_documents({"$nor": [{"$jsonSchema": schema}]}) rate = (non_compliant / total * 100) if total else 0.0 print(f"dry-run: {non_compliant}/{total} would be rejected ({rate:.4f}%)") if rate > 0.1: raise SystemExit(f"BLOCK: rejection rate {rate:.4f}% exceeds 0.1% threshold")Expected output on a clean change is
dry-run: 0/482173 would be rejected (0.0000%). A non-zero rate is the signal to run normalization before promotion, not to force the schema through. -
Promote to production under
moderatefirst. Only after all three gates pass, apply the schema — starting at the safe corner of the matrix so existing documents are grandfathered:db.runCommand({ collMod: "orders", validator: { $jsonSchema: /* proposed schema */ }, validationLevel: "moderate", validationAction: "warn" })Watch the warning volume, run your normalization job, then re-run
collModwithvalidationLevel: "strict"andvalidationAction: "error"to reach the enforced state. This mirrors the moderate-to-strict cutover detailed in how to enforce strict validation on existing collections.
Failure Modes & Rollback
Each phase has a distinct failure mode and a bounded recovery. Phase 1 and Phase 2 failures are cheap — the build stops, nothing changed on any cluster, time-to-recover is a code fix. The consequential failures happen at promotion.
-
Unsupported keyword slips through to
collMod. If your offline linter used a later draft and the keyword scan missed a nested case,collModfails withkeyword ... is not currently supportedand the collection is untouched — the command is atomic. Time-to-recover: seconds. Strip the keyword and re-run. -
Rejection spike after promotion to
error. A downstream service still emits documents the contract forbids, and code121errors surge. The soft rollback keeps the schema attached but stops rejecting, preserving compliance telemetry:// Soft rollback — restore write availability, keep collecting drift data. db.runCommand({ collMod: "orders", validationAction: "warn" })Time-to-recover is the replication lag of your slowest secondary — typically sub-second on a healthy replica set.
-
The schema itself was wrong. Remove the validator entirely and reinstate the previous version from your registry:
// Hard rollback — detach the validator completely. db.runCommand({ collMod: "orders", validator: {}, validationLevel: "off" })Treat hard rollback as a last resort: application code written against the new contract may now write documents the old readers cannot parse. For high-throughput paths, wrap writes so a
code == 121OperationFailureroutes the payload to a dead-letter queue instead of failing the request, giving you a durable buffer while you repair the schema.
Frequently Asked Questions
Does the Phase 3 dry-run using $jsonSchema modify the collection?
No. $jsonSchema is a read-only query operator, so countDocuments({ $nor: [ { $jsonSchema: <schema> } ] }) only counts documents that would fail the proposed validator. It never runs collMod, changes validationLevel/validationAction, or writes anything. It needs only the find action, which makes it safe to run against staging — or even a hidden secondary — from CI.
Why does my schema pass CI but fail at collMod time?
Almost always because the offline linter validated against JSON Schema Draft 7 or 2020-12, while MongoDB's server-side $jsonSchema is pinned to Draft 4 with extensions. Keywords like format, if/then/else, $ref, and unevaluatedProperties are rejected outright by the server rather than ignored. Pin your CI validator to Draft4Validator and run the unsupported-keyword scan so the divergence is caught in Phase 1.
What happens to in-flight writes when the pipeline runs collMod?
collMod acquires an exclusive (MODE_X) metadata lock on the namespace. The change is metadata-only and completes in milliseconds, but for that instant concurrent reads and writes on the collection block and wait on the lock — they are not lost. On a hot collection under peak throughput this can surface as a lock-wait timeout (code 64), which is why promotion belongs in a low-traffic window.
Related
- Schema Versioning Strategies for NoSQL — the parent workflow this linting gate enforces, covering versioned schema artifacts and staged rollout.
- MongoDB JSON Schema Validation Architecture — how
$jsonSchemais parsed, cached, and enforced at the storage layer this pipeline promotes into. - How to enforce strict validation on existing collections — the moderate-to-strict cutover the promotion step performs after the dry-run passes.
- JSON Schema Draft 4 vs Draft 2019 in MongoDB — why offline linters and the server disagree on keywords, and how to reconcile them.
- Python PyMongo validation wrapper scripts — reusable PyMongo helpers that wrap the dry-run and promotion calls used above.