JSON Schema Draft 4 vs Draft 2019 in MongoDB: Which Keywords Survive collMod?

MongoDB’s $jsonSchema validator implements JSON Schema Draft 4 on every server version, yet the client-side libraries most teams use for pre-flight checks — Python’s jsonschema, for instance — default to Draft 2019-09 or later, and reconciling that gap is the exact problem this page solves. It sits under Understanding MongoDB $jsonSchema Syntax within the broader MongoDB JSON Schema Validation Architecture, and the outcome you get here is a keyword-by-keyword map of what MongoDB honors, the precise collMod errors a later-draft schema throws, and a runnable playbook to migrate a shared schema artifact without stalling ingestion. This is not a cosmetic syntax difference: the draft you target changes which keywords exist, how conditional constraints must be written, and whether a validator even deploys.

The divergence matters most when the same schema definition is reused across two enforcement layers — client-side jsonschema running Draft 2019-09 against the full application payload, and server-side $jsonSchema running Draft 4 on the synchronous write path. A keyword that passes the client validator can hard-fail collMod, so migrating drafts safely intersects directly with schema versioning strategies for NoSQL and with fallback routing for invalid documents.

Operational Mechanics and Write-Path Impact

Draft 4 uses a flat, iterative keyword matcher. Arrays validate through positional items tuples bounded by additionalItems; cross-field constraints are expressed with dependencies; schema identity uses a bare id. Draft 2019-09 replaces this with a recursive evaluation model that tracks which properties and array indices were already evaluated, introduces $defs for reusable subschemas, and splits older keywords into narrower ones. Because MongoDB never advanced past Draft 4, every one of those newer keywords is rejected as unknown at collMod time rather than silently ignored — the validator refuses to deploy.

The practical mapping every migration must apply before a Draft 2019-09 artifact can run server-side:

Draft 2019-09 keyword MongoDB Draft 4 equivalent Migration action
$defs + $ref (none — no reference resolution) Inline every referenced subschema into the validator
dependentRequired / dependentSchemas dependencies Rewrite as a single dependencies object
unevaluatedProperties additionalProperties Close each object level explicitly with additionalProperties: false
unevaluatedItems additionalItems (or single-schema items) Use positional items + additionalItems: false, or one items schema
if / then / else oneOf / anyOf / allOf Re-express conditionals as combinators
$schema (none) Strip the meta-schema declaration entirely
prefixItems (Draft 2020-12, not 2019-09) positional items array Convert tuple validation to items: [ … ]

Two facts anchor the whole migration. First, prefixItems is a Draft 2020-12 keyword — in Draft 2019-09 tuple validation still uses items as an array of schemas, so do not expect it in a 2019-09 artifact. Second, unevaluatedProperties is not just unsupported but semantically heavier than its replacement: it runs after properties, patternProperties, and additionalProperties have all been applied, so even where a client validator supports it, it costs more per document than the additionalProperties: false you must fall back to on the server. The trade-off between checking every document and only newly-modified ones is governed separately by strict vs moderate validation levels.

Array handling is where silent corruption creeps in during a downgrade. A Draft 2019-09 schema that used unevaluatedItems: false to forbid extra elements must become either a single items schema applied to all elements, or a positional items array paired with additionalItems: false; the element-level patterns are covered in validating nested arrays with $jsonSchema.

Mapping Draft 2019-09 keywords onto a MongoDB Draft 4 validator Four Draft 2019-09 keywords have a Draft 4 equivalent that must be substituted before deployment: $defs is inlined as a literal subschema, dependentRequired becomes dependencies, unevaluatedProperties becomes additionalProperties set to false, and if/then/else is re-expressed as oneOf, anyOf or allOf. Two keywords have no equivalent at all — $ref, because MongoDB performs no reference resolution, and $schema, the meta-schema declaration — so both are rejected at collMod as unknown keywords and must be stripped from the server-side copy. Draft 2019-09 client schema jsonschema · Draft201909Validator MongoDB Draft 4 validator $jsonSchema · every server version $defs reusable subschemas inline Inlined subschema literal, no indirection dependentRequired conditional required keys rename dependencies single dependencies object unevaluatedProperties post-pass property check close object additionalProperties: false explicit at each level if / then / else conditional applicators combinators oneOf / anyOf / allOf re-expressed constraint $ref reference resolution strip $schema meta-schema declaration ✕ Rejected at collMod no Draft 4 equivalent — fails as an unknown $jsonSchema keyword. Strip from the server-side copy; keep it only in the client artifact.

Exact Diagnostic Fingerprints and Fast Resolution

Draft-mismatch failures surface as a small set of precise signatures. The server rejects unsupported keywords at deploy time and rejects non-compliant documents at write time with WriteError code 121.

Error signature Root cause Resolution
Unknown $jsonSchema keyword: unevaluatedProperties (at collMod) Later-draft keyword applied to a Draft 4 validator Replace with additionalProperties: false at that object level
Unknown $jsonSchema keyword: $ref (at collMod) Reference resolution does not exist server-side Inline the referenced subschema; keep $ref only in the client copy
Unknown $jsonSchema keyword: if (at collMod) Conditional applicators are Draft 7+ Rewrite as oneOf / anyOf / allOf
Document failed validation (WriteError code 121) A document violates the deployed schema Inspect errInfo.details.schemaRulesNotSatisfied for the failing path
Documents pass the client validator but fail on write Client draft accepts a shape Draft 4 rejects (or vice versa) Diff the two schemas; the server contract is authoritative

The fastest way to confirm a keyword problem versus a data problem is to apply the candidate schema to a throwaway namespace and read the server’s own error text — an unsupported keyword fails immediately, before any document is written:

from pymongo import MongoClient
from pymongo.errors import OperationFailure

client = MongoClient("mongodb://localhost:27017")
db = client["appdb"]

candidate = {
    "bsonType": "object",
    "required": ["order_id"],
    "unevaluatedProperties": False,  # Draft 2019-09 keyword MongoDB will reject
    "properties": {"order_id": {"bsonType": "string"}},
}

db.drop_collection("__draft_probe")
try:
    db.create_collection("__draft_probe", validator={"$jsonSchema": candidate})
    print("OK: schema is Draft 4 compatible")
except OperationFailure as exc:
    print("REJECTED:", exc.details.get("errmsg", str(exc)))
finally:
    db.drop_collection("__draft_probe")

Expected output for the schema above:

REJECTED: Unknown $jsonSchema keyword: unevaluatedProperties

Because $jsonSchema is also a valid query operator, you can size the blast radius of a draft downgrade against real data before enforcing anything — this counts documents the ported Draft 4 schema would reject, with no validator active:

// mongosh — draft4Schema is your inlined, keyword-mapped version
db.orders.countDocuments({ $nor: [ { $jsonSchema: draft4Schema } ] })

Step-by-Step Playbook

The migration path below downgrades a Draft 2019-09 artifact to a Draft 4 validator and deploys it without rejecting live writes. Each step is reversible.

Three-stage downgrade rollout from Draft 2019-09 to a Draft 4 validator Stage 1 ports the keywords from Draft 2019-09 to Draft 4 on a scratch namespace. Stage 2 attaches the ported validator to the live collection with validationLevel moderate and validationAction warn, so non-compliant writes succeed but emit server log message 20294; a Change Stream consumer branches those warnings to a quarantine queue or dead-letter queue. Once the warning rate settles, Stage 3 promotes the same schema to validationLevel strict and validationAction error, at which point violating writes fail with WriteError code 121. STAGE 1 Port keywords Draft 2019 → Draft 4 STAGE 2 Deploy in warn moderate + warn STAGE 3 Promote to error strict + error attach warnings settle warn · log 20294 Quarantine / DLQ routed via Change Streams
  1. Port the keywords. Strip $schema and $ref, inline $defs, and rewrite if/then/else as combinators and unevaluatedProperties as additionalProperties: false. Verify the ported schema on a scratch namespace using the probe from the previous section — it must print OK before you proceed.

  2. Count non-compliant documents. Run the $nor + $jsonSchema count above. A non-zero result means a strict cutover would reject updates to those documents, so plan a moderate rollout or clean the data first.

  3. Deploy in warn to collect telemetry. Attach the ported schema without rejecting any write:

    db.runCommand({
      collMod: "orders",
      validator: { $jsonSchema: draft4Schema },
      validationLevel: "moderate",
      validationAction: "warn"
    })

    Expected result: { ok: 1 }. Non-compliant writes now emit a warning to the server log (message id 20294) instead of failing, letting a Change Stream consumer route offending payloads to a dead-letter queue.

  4. Promote to error once warnings settle. When the warning rate drops below your threshold, flip the action — the schema definition itself does not change:

    db.runCommand({ collMod: "orders", validationAction: "error" })

    Expected result: { ok: 1 }. New writes that violate the Draft 4 contract now fail with WriteError code 121.

For high-throughput ingestion, keep the client-side jsonschema pre-flight in place using jsonschema.Draft201909Validator against the richer schema, and let the server enforce the flattened Draft 4 equivalent — this shifts most validation cost to stateless application nodes. Wiring the two definitions to a single version-controlled source is the job of schema versioning strategies for NoSQL.

Failure Modes & Rollback

Failure at step Symptom Recovery
Step 1 — incomplete port Unknown $jsonSchema keyword on collMod Re-run the probe; map the named keyword and retry (no production impact — the scratch namespace absorbs it)
Step 3 — warn masks a broken port Silent flood of log 20294 warnings Read the warnings, fix the schema, re-apply; writes were never blocked
Step 4 — error cutover too early Ingestion 121 rejection spike Soft-rollback to warn immediately (below); time-to-recover is seconds

The soft rollback keeps the schema attached for telemetry but stops rejecting writes; the hard rollback detaches the validator entirely:

// Soft rollback — restore write availability, keep the contract observable.
db.runCommand({ collMod: "orders", validationAction: "warn" })

// Hard rollback — remove the validator completely.
db.runCommand({ collMod: "orders", validator: {}, validationLevel: "off" })

Both commands are metadata-only, take effect immediately on the primary, and propagate through the oplog; time-to-recover for the soft rollback is effectively the replication lag of your slowest secondary. Because collMod takes an exclusive collection lock while it swaps validator metadata, run cutovers and rollbacks in a low-throughput window and confirm the applied state afterward:

db.getCollectionInfos({ name: "orders" })[0].options

Frequently Asked Questions

Can I set $schema to Draft 2019-09 to make MongoDB use that draft?

No. MongoDB does not read $schema at all — it implements Draft 4 unconditionally, and declaring $schema inside a validator fails at collMod as an unknown keyword. Strip it before deployment and keep it only in the client-side artifact that jsonschema consumes.

Is prefixItems supported for tuple validation?

No — prefixItems is a Draft 2020-12 keyword, and MongoDB is Draft 4. Even a Draft 2019-09 client schema would not use it; 2019-09 expresses tuples as items given an array of schemas. Server-side, use positional items plus additionalItems: false.

Why does a schema that passes my Python jsonschema check still fail on write?

Because the two layers run different drafts. A Draft 2019-09 client validator can accept a shape that the server's Draft 4 $jsonSchema rejects (or vice versa, when a ported keyword narrows a constraint). Treat the deployed server validator as authoritative and diff the two definitions from a single versioned source.