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.

Three-phase MongoDB schema-linting gate, escalating from cheap-and-local to authoritative-and-remote A committed schema.json artifact flows left to right through three escalating phases. Phase 1 is a static Draft 4 meta-validation run locally with check_schema; it blocks the merge on invalid syntax. Phase 2 samples ten thousand staging documents offline and validates them with is_valid; it blocks on sampled drift. Phase 3 is a live cluster dry-run that counts non-compliant documents with a $nor $jsonSchema query using only the find permission; it blocks when the rejection rate exceeds 0.1 percent. A decision node asks whether all gates pass: if yes, the schema is promoted via collMod starting in moderate plus warn; if no, the merge is blocked and the job exits non-zero on the first hard failure. cheaper · local authoritative · remote Commit schema.json PHASE 1 · LOCAL Static Draft 4 meta-validation check_schema() PHASE 2 · OFFLINE Offline sampling $sample 10k docs is_valid() PHASE 3 · REMOTE Cluster dry-run count non-compliant $nor · $jsonSchema All gates pass? Block merge exit non-zero on first hard failure — nothing reaches the database Promote via collMod start moderate + warn then strict + error invalid syntax sampled drift rejection > 0.1% no yes

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.

  1. Meta-validate and scan the artifact (Phase 1). Load the committed schema and run Draft4Validator.check_schema plus the unsupported-keyword scan shown above. This is deterministic and needs no database — fail the build immediately on any hit.

  2. 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]})")
  3. Run the live cluster dry-run (Phase 3). Ask the staging cluster for the exact rejection rate using $nor + $jsonSchema. No collMod, 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.

  4. Promote to production under moderate first. 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 collMod with validationLevel: "strict" and validationAction: "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, collMod fails with keyword ... is not currently supported and 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 code 121 errors 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 == 121 OperationFailure routes 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.