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.
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.
-
Attach the typed schema under
moderate+warn. ThebsonTypeconstraint 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 } -
Coerce in place with an aggregation-pipeline
updateMany. The pipeline references each field’s current value and rewrites its type. Use$convertwith anonError/onNullsentinel 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";$toDateparses ISO-8601, and an ambiguous or garbage string hits theonErrorbranch:db.events.updateMany( { created_at: { $type: "string" } }, [{ $set: { created_at: { $convert: { input: "$created_at", to: "date", onError: null } } } }] ) -
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
-1to fail theminimum: 0rule 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. -
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: 0forqtywrites a plausible wrong value that passes the schema, silently corrupting data. Always use an out-of-range sentinel (-1under aminimum: 0rule) ornull, so the failure stays visible and dead-letterable. onErrorswallowing a systemic problem (step 2). If a large fraction of rows hitonError, 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
doublesuch as19.99todecimalpreserves 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
updateManyover 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.
Related
- Document Transformation Pipelines for Schema Migration — the parent pipeline this coercion specialises, with batching, throttling, and dead-letter routing.
- Zero-Downtime Schema Migration Automation in MongoDB — the overarching migration framework this type-correction fits inside.
- Backfilling New Required Fields in Batches — the sibling playbook for the different failure class of a field that is missing entirely.
- Strict vs Moderate Validation Levels — why coercing under moderate grandfathers updates to the currently mis-typed documents.
- Fallback Routing for Invalid Documents — where to send values that cannot be coerced to the required type.