Coercing bsonType Mismatches In Place

When a field was persisted under the wrong storage type — the string "123" where the schema now demands an int, an ISO string where it demands a date, or a double where money must be decimal — a $jsonSchema tightening will reject every document carrying the wrong type, and this page is the playbook for coercing those values in place with a server-side aggregation-pipeline update. It sits under document transformation pipelines within the broader Zero-Downtime Schema Migration Automation in MongoDB framework. The reader outcome is a collection where every value already holds the correct BSON type, so promoting the validator to strict + error adds no rejections. The technique is an updateMany whose second argument is an aggregation pipeline using $convert (and its shorthands $toInt, $toDate, $toDecimal), which rewrites each field from inside the server without ever pulling the document to the client. The judgement call the whole page turns on is what to do with a value that cannot be coerced — a "N/A" where an int is required — and the answer is a $convert onError/onNull branch that tags the document so it can be routed to a dead-letter collection rather than silently zeroed.

Operational Mechanics and Write-Path Impact

A bsonType mismatch is a different failure class from a missing field. The property is present, so a partial $set to an unrelated field does not, on its own, fail — but the moment enforcement is strict + error, any write that touches the mis-typed document re-validates the whole document and is rejected because that one field is the wrong type. The correction has to change the stored type, not just the value, and that is exactly what an aggregation-pipeline updateMany does: unlike a classic $set with a literal, a pipeline stage can reference the field’s own current value and transform it, so {$set: {qty: {$toInt: "$qty"}}} reads the string "123" and writes the integer 123 back into the same field.

MongoDB’s $convert is the general form and the only one that can handle failure gracefully. The shorthands are terser but throw on a bad value; $convert lets you supply onError and onNull so an un-coercible value produces a sentinel instead of aborting the batch. The type helpers you will reach for map directly onto the bsonType you are converging on:

Source value Target bsonType Expression Notes
"123" (string) int $toInt / $convert to: "int" Fails on "12.5" or non-numeric text
"2026-07-17" (string) date $toDate / $convert to: "date" Parses ISO-8601; ambiguous formats fail
19.99 (double) decimal $toDecimal / $convert to: "decimal" Use for money to avoid binary-float error
"123" (string) long $toLong / $convert to: "long" For 64-bit counters stored as text
1 / 0 (int) bool $convert to: "bool" Non-zero becomes true

Run the coercion under validationLevel: "moderate" so updates to the currently-mis-typed (therefore non-compliant) documents are grandfathered while you rewrite them — the grandfathering rule is detailed in strict vs moderate validation levels. Because $convert runs entirely server-side, the coercion generates one oplog entry per document and never round-trips data to the client, which makes it markedly faster than a client-side transform for a pure type change — but it still floods the oplog at volume, so the same batching-and-throttle discipline from the parent pipeline applies.

Server-side type coercion with a $convert onError branch A mis-typed field, for example the string 123 where an int is required, enters a server-side $convert expression inside an aggregation-pipeline updateMany. If the value is coercible, $convert writes the correct BSON type back into the same field and the document becomes compliant. If the value cannot be coerced, the onError branch produces a sentinel that tags the document, which a follow-up pass routes to a dead-letter collection for manual review instead of overwriting the original value. Mis-typed field "123" as string $convert onError branch Write correct type int 123 · same field Dead-letter collection un-coercible · tagged coercible onError

Exact Diagnostic Fingerprints and Fast Resolution

Before coercing, count the documents whose field holds the wrong type. The $type query operator is the precise probe — it matches on stored BSON type, so {qty: {$type: "string"}} finds exactly the strings that must become integers:

// How many docs store qty as a string instead of an int?
db.line_items.countDocuments({ qty: { $type: "string" } })
// Expected output: an integer, e.g. 8123

Once the schema demands the correct type and enforcement is live, a residual mis-typed document produces a bsonType failure — distinct from the required failure of a missing field. The signatures to match:

Signature Where it appears Root cause Resolution
code: 121, failing keyword bsonType, path qty Driver / client after promotion to error A document still stores the wrong type Read errInfo.details.schemaRulesNotSatisfied; re-run the coercion for that value
"Failed to parse number ..." / conversion error in a pipeline Aggregation update without onError A value like "N/A" cannot be converted Add an onError branch and route the document to a dead-letter
Documents silently set to 0 or null after coercion onError/onNull used a default value instead of a sentinel Un-coercible values were masked, not surfaced Use a distinguishable sentinel and dead-letter, never a plausible default

The distinction between a bsonType failure and the required failure handled by backfilling new required fields in batches matters for triage: a bsonType mismatch means the value exists but is stored wrong, so coercion can usually fix it; a required miss means there is no value at all. The schemaRulesNotSatisfied keyword tells you which class you are in.

Step-by-Step Playbook

The sequence rewrites the field’s stored type in throttled batches, tags anything un-coercible for dead-letter routing, and tightens enforcement only when the mismatch count is zero.

  1. Attach the typed schema under moderate + warn. The bsonType constraint is defined but blocks nothing:

    db.runCommand({
      collMod: "line_items",
      validator: { $jsonSchema: {
        bsonType: "object",
        required: ["sku", "qty", "unit_price"],
        properties: {
          sku: { bsonType: "string" },
          qty: { bsonType: "int", minimum: 0 },
          unit_price: { bsonType: "decimal", minimum: 0 }
        }
      }},
      validationLevel: "moderate",
      validationAction: "warn"
    })
    // Expected output: { ok: 1 }
  2. Coerce in place with an aggregation-pipeline updateMany. The pipeline references each field’s current value and rewrites its type. Use $convert with an onError/onNull sentinel so an un-coercible value tags the document instead of aborting the batch or writing a misleading default:

    db.line_items.updateMany(
      { $or: [
          { qty: { $type: "string" } },
          { unit_price: { $type: "double" } }
      ]},
      [
        { $set: {
            // String qty -> int; un-coercible values get a sentinel + flag.
            qty: {
              $convert: {
                input: "$qty", to: "int",
                onError: -1,          // sentinel: impossible real qty
                onNull: -1
              }
            },
            // double price -> decimal for exact money arithmetic.
            unit_price: {
              $convert: { input: "$unit_price", to: "decimal", onError: null }
            }
        }},
        { $set: {
            // Flag rows the conversion could not handle, for dead-letter routing.
            _coerce_failed: {
              $or: [ { $eq: ["$qty", -1] }, { $eq: ["$unit_price", null] } ]
            }
        }}
      ]
    )
    // Expected output: { matchedCount: 8123, modifiedCount: 8123 }

    For string-to-date fields the same shape applies with to: "date"; $toDate parses ISO-8601, and an ambiguous or garbage string hits the onError branch:

    db.events.updateMany(
      { created_at: { $type: "string" } },
      [{ $set: { created_at: { $convert: { input: "$created_at", to: "date", onError: null } } } }]
    )
  3. Route the tagged documents to a dead-letter, then clear the flag. Move anything the conversion could not handle out of the write path for human review via fallback routing for invalid documents, rather than leaving a sentinel -1 to fail the minimum: 0 rule later:

    from pymongo import MongoClient
    
    client = MongoClient("mongodb://primary:27017/?replicaSet=rs0")
    db = client.production_db
    
    for doc in db.line_items.find({"_coerce_failed": True}):
        db.line_items_deadletter.insert_one({"source_id": doc["_id"], "document": doc})
        db.line_items.delete_one({"_id": doc["_id"]})
    # Sentinel rows are now quarantined, not silently wrong in the live collection.
  4. Verify the mismatch count is zero, then promote. Confirm no field still holds the wrong type, then flip enforcement — an atomic, metadata-only change:

    db.line_items.countDocuments({ $or: [
      { qty: { $type: "string" } }, { unit_price: { $type: "double" } }
    ]})
    // Expected output: 0
    
    db.runCommand({ collMod: "line_items", validationLevel: "strict", validationAction: "error" })
    // Expected output: { ok: 1 }

Failure Modes & Rollback

Each step has a characteristic failure and a bounded recovery:

  • A masked default instead of a sentinel (step 2). Setting onError: 0 for qty writes a plausible wrong value that passes the schema, silently corrupting data. Always use an out-of-range sentinel (-1 under a minimum: 0 rule) or null, so the failure stays visible and dead-letterable.
  • onError swallowing a systemic problem (step 2). If a large fraction of rows hit onError, the source format is not what you assumed — stop and inspect, rather than dead-lettering half the collection. Coercion is for stragglers, not for a wholesale format change.
  • Decimal precision surprises (step 2). Converting a double such as 19.99 to decimal preserves the binary-float representation error already baked into the double. Where exact money matters, coerce from the original string source if one exists, not from the lossy double.
  • Oplog pressure at volume (steps 2–3). A single unbounded updateMany over millions of rows floods the oplog. For large collections, drive the coercion in _id-ranged batches and watch replication lag, exactly as the parent document transformation pipelines module does.

Rollback of enforcement is one metadata-only collMod; time-to-recover is the replication lag of the slowest secondary, typically seconds:

// Soft rollback — stop rejecting, keep the schema attached for telemetry.
db.runCommand({ collMod: "line_items", validationLevel: "moderate", validationAction: "warn" })

// Hard rollback — detach the validator entirely for emergency recovery.
db.runCommand({ collMod: "line_items", validator: {}, validationLevel: "off" })

The coerced values are not reverted by a rollback, and generally should not be — an int 123 is more correct than the string "123". The one genuinely destructive case is a bad onError default that overwrote a real value; that cannot be recovered from the $set and must be restored from a point-in-time backup, which is why the coercion should always be dry-run against a copied collection before it touches production.

Frequently Asked Questions

Why use an aggregation-pipeline updateMany instead of a plain $set?

A plain $set can only write a literal value; it cannot read the field's current value to transform it. An aggregation-pipeline update — the array form of the second argument — lets each stage reference the existing field with a dollar-sign path, so an expression like toInt of dollar-qty reads the stored string and writes the integer back into the same field. That self-referential rewrite is exactly what a type coercion needs, and it runs entirely server-side without pulling documents to the client.

What should onError return for an un-coercible value?

A distinguishable sentinel, never a plausible default. If a string qty cannot become an int, returning 0 writes a value that passes the schema and silently corrupts the data. Return an out-of-range sentinel such as -1 under a minimum-0 rule, or null, then flag and route those documents to a dead-letter collection. The goal is to keep the failure visible and reviewable, not to paper over it with a value that looks real.

Is it safe to coerce a double to decimal for currency fields?

The conversion itself is safe and is the right target type for money, but it does not undo error already present in the double. A value stored as the binary float 19.99 carries a tiny representation error, and toDecimal preserves that error rather than recovering the exact 19.99. If an exact original exists as a string, coerce from that source; otherwise accept that decimal fixes future arithmetic precision but cannot retroactively clean a value that was already lossy as a double.