Understanding MongoDB $jsonSchema Syntax

Within the broader MongoDB JSON Schema Validation Architecture, the $jsonSchema operator is the vocabulary you use to turn a flexible document store into a contract-enforced data platform. This guide is a complete syntax and deployment reference for platform teams, data engineers, and Python automation builders: it maps every keyword MongoDB actually honors, shows how the validator is evaluated on the synchronous write path, and gives you runnable pymongo and mongosh code to define, deploy, diagnose, and roll back a schema safely. Because $jsonSchema is evaluated inline on every insert, update, and replace, syntax precision here directly determines write latency, throughput, and how cleanly failures surface downstream. The deliverable by the end of the page is a schema you can express correctly the first time and a repeatable deployment function that never surprises production.

Architectural Context & Enforcement Boundaries

A $jsonSchema document is not a passive annotation — it is compiled into a query predicate that MongoDB runs against every candidate write before the document is committed to storage. The validator is attached to a collection through createCollection or collMod and wrapped in a validator: { $jsonSchema: … } object. When a write arrives, the engine parses the BSON payload, checks declared required keys, evaluates each property’s bsonType and value constraints, recurses into nested objects and array items, and short-circuits on the first violation to keep per-write CPU cost bounded.

How a $jsonSchema validator evaluates one write, short-circuiting on the first failing rule A client insert, update or replace enters a synchronous validation engine that runs four ordered gates before the write is committed: parse the BSON payload, check bsonType and required keys, evaluate property constraints such as enum, pattern and ranges, then recurse into nested objects and array items. Passing every gate accepts the write and persists it durably. The moment any single gate fails, evaluation stops immediately and the write is rejected as a DocumentValidationFailure with WriteError code 121 — no later gate runs. Synchronous $jsonSchema evaluation — runs inline on every write, before commit Client write insert · update · replace 1 Parse BSON read payload 2 Types & keys bsonType · required 3 Properties enum · pattern · range 4 Recurse nested · array items Accept write persist · durable pass first failing rule DocumentValidationFailure WriteError · code 121 · no later gate runs

This synchronous position is what makes syntax an operational concern rather than a stylistic one. $jsonSchema is the innermost enforcement boundary, and it pairs with the wider deployment machinery of collection-level validators that governs how the schema is applied. The operator only guards insert, update, replace, and findAndModify; the aggregation stages $out and $merge bypass the destination collection’s validator entirely, so any integrity contract that must survive analytics rewrites has to be reasserted through cross-collection validation patterns rather than assumed. Writes that fail can be diverted instead of dropped through fallback routing for invalid documents.

Prerequisites & Operational Requirements

The syntax and diagnostics below assume a supported production topology. Confirm the following before applying any schema to a live collection.

  • MongoDB version: 5.0 or later. The structured schemaRulesNotSatisfied array inside a validation error — the field that tells you which rule and JSON path failed — was introduced in 5.0. On 4.x you get only code: 121 with a generic "Document failed validation" message and no path-level detail.
  • Driver: PyMongo 4.x (pip install "pymongo>=4.6,<5"). Pin the driver in your automation image so collMod argument handling and the WriteError / OperationFailure exception classes stay stable across builds.
  • Permissions: the deploying principal needs the collMod action on the target collection, granted by the built-in dbAdmin role. A dry-run compliance count additionally needs find (read).
  • Topology: a replica set, not a standalone. collMod propagates through the oplog, so schema changes are subject to replication lag on your slowest secondary, and you will want a maintenance window because applying a validator takes an exclusive collection lock.
  • Draft baseline: MongoDB implements JSON Schema Draft 4 semantics on every server version, including current releases. It omits $ref, $schema, default, definitions, format, and id, and it rejects — rather than silently ignores — later-draft keywords such as if/then/else, $defs, and unevaluatedProperties at collMod time. When you share schema artifacts with client tooling that defaults to a newer draft, reconcile the differences against JSON Schema Draft 4 vs Draft 2019 in MongoDB.

The two enforcement dials that accompany the schema are validationLevel and validationAction. Their combination decides which documents are checked and what happens on failure:

validationAction validationLevel Checks applied to On failure
warn moderate Inserts + updates to already-valid docs Logs a warning; write succeeds
warn strict All inserts and updates Logs a warning; write succeeds
error moderate Inserts + updates to already-valid docs Rejects with WriteError code 121
error strict All inserts and updates Rejects with WriteError code 121

moderate is the migration-safe choice when hardening a collection that predates the schema; the trade-offs are covered in depth under strict vs moderate validation levels.

Core Syntax & BSON Type Mapping

The single most important difference from standard JSON Schema is that MongoDB validates against BSON types, not JSON types. The bsonType keyword is the MongoDB extension that exposes the full type system; the standard type keyword also works but can only express the JSON subset. A schema is a nested object of constraints rooted at bsonType: "object".

const schema = {
  $jsonSchema: {
    bsonType: "object",
    required: ["_id", "tenant_id", "event_type", "payload"],
    additionalProperties: false,
    properties: {
      _id: { bsonType: "objectId" },
      tenant_id: { bsonType: "string", minLength: 24, maxLength: 24 },
      event_type: { enum: ["login", "transaction", "audit"] },
      payload: {
        bsonType: "object",
        additionalProperties: false,
        properties: {
          amount: { bsonType: "decimal", minimum: 0 },
          metadata: { bsonType: "object" }
        }
      },
      created_at: { bsonType: "date" }
    }
  }
}

The bsonType values that have no plain-JSON equivalent are where most real bugs live. The mapping every schema author should keep in front of them:

bsonType Matches Common mistake it prevents
objectId 12-byte ObjectId Accepting a 24-char hex string where an id is expected
decimal Decimal128 Storing currency as double and inheriting float rounding
int / long 32-bit / 64-bit integer A whole number silently stored as double
date BSON Date An ISO-8601 string passing an unspecified field
binData binary subtype Base64 text where raw bytes belong
timestamp internal BSON Timestamp Confusing it with date (they are distinct types)
null the BSON null value A missing key — which null does not match

Operational notes that follow directly from the type system:

  • additionalProperties: false is what turns a schema into a closed contract. Omit it and undeclared fields flow straight through, which is how uncontrolled drift enters aggregation pipelines and breaks downstream consumers.
  • required validates only the keys you list, at whatever nesting level you declare it. It asserts presence, not type — pair every required key with a properties entry that constrains its bsonType.
  • Numeric width is part of the contract. A schema demanding bsonType: "double" rejects an integer literal because MongoDB stored it as int. When width is irrelevant, use bsonType: ["double", "int", "long", "decimal"].
  • Validation cost scales with schema depth and pattern complexity. For high-throughput ingestion, keep the on-write schema shallow and push expensive structural checks to application-layer pre-validation.

Idempotent Deployment / Implementation Workflow

A production schema deployment must be deterministic: running it twice must not mutate collection metadata twice, and it must never take an exclusive lock it does not need. Follow this sequence from mongosh or its pymongo equivalent.

  1. Inspect the current validator. It lives in the collection’s options, retrieved through listCollections — not in collStats, which returns storage metrics only:

    db.getCollectionInfos({ name: "events" })[0].options.validator
  2. Count non-compliant documents with the schema as a query. $jsonSchema is a valid query operator, so you can measure the blast radius before enforcing anything, with no validator active:

    db.events.countDocuments({ $nor: [ { $jsonSchema: schema.$jsonSchema } ] })

    A non-zero count means a strict rollout would reject updates to those documents; use moderate or clean the data first.

  3. Apply the validator in warn first. This attaches the schema and starts emitting telemetry without rejecting writes, so you can watch real traffic against the contract:

    db.runCommand({
      collMod: "events",
      validator: schema,
      validationLevel: "moderate",
      validationAction: "warn"
    })
  4. Promote to error once the rejection rate is acceptable. The promotion is a second, reversible collMod; nothing about the schema definition changes:

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

Conditional and array constraints slot into the same schema object. Because MongoDB is Draft 4, conditional requirements are expressed with the oneOf / anyOf / allOf combinators rather than if/then. Here oneOf demands stricter payload fields only when event_type is transaction:

const conditionalSchema = {
  $jsonSchema: {
    bsonType: "object",
    required: ["event_type", "payload"],
    oneOf: [
      {
        properties: {
          event_type: { enum: ["transaction"] },
          payload: {
            bsonType: "object",
            required: ["amount", "currency"],
            properties: {
              amount: { bsonType: "decimal", minimum: 0 },
              currency: { bsonType: "string", pattern: "^[A-Z]{3}$" }
            }
          }
        }
      },
      { properties: { event_type: { enum: ["login", "audit"] } } }
    ]
  }
}

Arrays are validated element-by-element through the items keyword, not as a monolithic block, which is where silent corruption creeps into event-sourced data. Always pair items with minItems / maxItems to bound growth; the deeper patterns are covered in validating nested arrays with $jsonSchema:

const arraySchema = {
  $jsonSchema: {
    bsonType: "object",
    properties: {
      line_items: {
        bsonType: "array",
        minItems: 1,
        maxItems: 500,
        items: {
          bsonType: "object",
          required: ["sku", "quantity"],
          properties: {
            sku: { bsonType: "string", pattern: "^[A-Z0-9]{8,12}$" },
            quantity: { bsonType: "int", minimum: 1 }
          }
        }
      }
    }
  }
}

Production-Ready Automation Implementation

Platform teams managing dozens of collections need schema deployment to be a function, not a console session. The pattern below applies a schema safely from pymongo: collMod has no dry-run flag, so it first exercises the schema on a throwaway namespace to catch syntax errors — including rejected non–Draft-4 keywords — before touching production, then applies it with explicit error boundaries and logging. Wiring this into a version-controlled registry is what makes it auditable; see schema versioning strategies for NoSQL.

import logging

import pymongo
from pymongo.errors import OperationFailure, ServerSelectionTimeoutError

logger = logging.getLogger("schema_governance")


def apply_json_schema(
    client: pymongo.MongoClient,
    db_name: str,
    collection_name: str,
    schema: dict,
    validation_level: str = "moderate",
    validation_action: str = "error",
) -> bool:
    """Deploy a $jsonSchema validator with a syntax dry-run and explicit error handling.

    Step 1 creates a temporary collection with the proposed schema to verify syntax,
    because collMod has no dry-run flag. Step 2 applies the validated schema to the
    target collection via collMod. Returns True on success, False on a handled failure.
    """
    db = client[db_name]
    temp_name = f"__schema_dryrun_{collection_name}"

    try:
        # Step 1: syntax dry-run on a throwaway namespace.
        db.drop_collection(temp_name)
        try:
            db.create_collection(
                temp_name,
                validator={"$jsonSchema": schema},
                validationLevel=validation_level,
                validationAction=validation_action,
            )
            logger.info("Dry-run passed for %s.%s", db_name, collection_name)
        finally:
            db.drop_collection(temp_name)

        # Step 2: apply the validated schema to the production collection.
        db.command(
            "collMod",
            collection_name,
            validator={"$jsonSchema": schema},
            validationLevel=validation_level,
            validationAction=validation_action,
        )
        logger.info("Schema applied to %s.%s", db_name, collection_name)
        return True

    except OperationFailure as exc:
        detail = exc.details.get("errmsg", str(exc)) if exc.details else str(exc)
        logger.error("Schema deployment failed: %s", detail)
        return False
    except ServerSelectionTimeoutError as exc:
        logger.critical("Cluster unreachable during schema deployment: %s", exc)
        raise

Key automation practices:

  • Always dry-run on a temporary collection first (Step 1). It is the only way to catch a syntax violation or an unsupported keyword without locking the production collection.
  • Embed a schema_version field in your documents so drift-detection and rollback scripts have a machine-readable anchor.
  • Use retryable writes and connection pooling in CI/CD pipelines so a transient primary election does not fail an otherwise valid deployment.
  • Drive the deployment from infrastructure-as-code (Terraform, Ansible) so staging and production converge on the same schema idempotently.

Diagnostic Fingerprints & Fast Resolution

When a validator rejects a write, MongoDB returns a WriteError with code: 121 and errmsg: "Document failed validation". On 5.0+, the driver payload carries a details object whose schemaRulesNotSatisfied array isolates the exact failing rule and JSON path:

{
  "code": 121,
  "errmsg": "Document failed validation",
  "errInfo": {
    "failingDocumentId": "…",
    "details": {
      "operatorName": "$jsonSchema",
      "schemaRulesNotSatisfied": [
        {
          "operatorName": "properties",
          "propertiesNotSatisfied": [
            {
              "propertyName": "amount",
              "details": [
                { "operatorName": "bsonType", "specifiedAs": { "bsonType": "decimal" }, "reason": "type did not match" }
              ]
            }
          ]
        }
      ]
    }
  }
}

In PyMongo this surfaces as pymongo.errors.WriteError for a single write, or BulkWriteError for a batch; inspect exc.details["errInfo"]["details"] to reach the structure above. A copy-paste diagnostic that reproduces the failing set from mongosh — no validator changes required:

// Show every document that would fail the schema, with the rule that broke.
db.events.find({ $nor: [ { $jsonSchema: schema.$jsonSchema } ] })
  .limit(20).forEach(d => printjson({ _id: d._id, event_type: d.event_type }))

To confirm whether the failure is a schema problem versus a driver or keyword problem, apply the schema to a scratch collection and read the server’s own error text — an unsupported keyword surfaces as Unknown $jsonSchema keyword: <name> at collMod time, which is a portability bug, not a data bug.

Edge Cases, Gotchas & Known Limitations

  • Later-draft keywords hard-fail. if/then/else, $defs, unevaluatedProperties, and $ref are rejected as unknown keywords at collMod time. Translate them to oneOf/anyOf/allOf before deployment.
  • null is not missing. bsonType: "null" matches a field whose value is BSON null; it does not satisfy required for an absent key, and it does not match a missing field. These are three distinct states.
  • Aggregation writes bypass validation. $out and $merge do not run the destination collection’s validator, so a pipeline can legally write documents your schema would reject. Re-validate or constrain the pipeline output explicitly.
  • Validators are not indexes. $jsonSchema cannot enforce uniqueness — that requires a unique index. Schema validation catches malformed data; indexes enforce identity and optimize retrieval. They are complementary, not interchangeable.
  • Trusting the schema for authorization is a mistake. A validator constrains shape, not who may write; tenant isolation and field-level access belong to security boundaries in schema design.
  • Deep schemas tax every write. Because evaluation is synchronous and recursive, a heavily nested schema with expensive pattern regexes adds measurable per-write latency under load. Profile with explain() before shipping a complex schema to a hot collection.

Verification & Rollback Procedures

After a deployment, verify that the live validator matches what you intended and that new writes behave as expected:

// 1. Confirm the applied validator, level, and action.
db.getCollectionInfos({ name: "events" })[0].options

// 2. Confirm a known-bad document is now rejected (should throw code 121).
db.events.insertOne({ event_type: "transaction", payload: {} })

If the deployment misbehaves — an unexpected rejection spike or a downstream service that cannot yet meet the contract — rollback is a single, reversible collMod. Dropping to warn restores write availability instantly while keeping the schema attached for telemetry:

// Soft rollback: keep the schema, stop rejecting (time-to-recover: seconds).
db.runCommand({ collMod: "events", validationAction: "warn" })

// Hard rollback: remove the validator entirely.
db.runCommand({ collMod: "events", validator: {}, validationLevel: "off" })

Both commands are metadata-only and take effect immediately on the primary, propagating to secondaries through the oplog; time-to-recover for the soft rollback is effectively the replication lag of your slowest secondary. For authoritative operator behavior and edge cases, consult the official MongoDB $jsonSchema documentation.

Frequently Asked Questions

Which JSON Schema draft does MongoDB's $jsonSchema implement?

Draft 4 semantics on every server version, including current releases. It omits $ref, $schema, default, definitions, format, and id, and it rejects later-draft keywords such as if/then/else, $defs, and unevaluatedProperties at collMod time rather than ignoring them. Use oneOf, anyOf, and allOf for conditional logic.

What is the difference between bsonType and type?

type only expresses the JSON subset (string, number, object, array, boolean, null). bsonType is the MongoDB extension that reaches the full BSON type system — objectId, decimal, int, long, date, binData, timestamp. Prefer bsonType whenever type precision matters, which for stored data is almost always.

Why does a numeric field still fail bsonType: "double"?

Because MongoDB distinguishes numeric BSON types. An integer literal is stored as int (or long), not double, so a schema demanding "double" rejects it. When numeric width is not part of the contract, specify bsonType: ["double", "int", "long", "decimal"].

Can I test which existing documents fail a schema before enforcing it?

Yes. $jsonSchema is a valid query operator, so db.coll.countDocuments({ $nor: [ { $jsonSchema: <schema> } ] }) counts non-compliant documents with no validator active. This is how you size the blast radius of a strict rollout before committing to it.

Does additionalProperties: false apply to nested objects automatically?

No. It only closes the object at the level where you declare it. Nested objects remain open unless each one repeats additionalProperties: false. Undeclared fields on a nested object flow through silently otherwise, which is a common source of schema drift.