Building Custom Error Payloads for Schema Violations in MongoDB
MongoDB always rejects a $jsonSchema violation with the same opaque WriteError code 121, and its raw errInfo tree rarely survives intact through application routers, message queues, or Python ETL frameworks — so downstream consumers cannot reliably alert, quarantine, or auto-remediate on it. This page is the runnable playbook for turning that raw rejection into a stable, machine-readable custom error payload; it sits under categorizing schema validation errors inside the broader Automated Schema Enforcement & Monitoring framework. Where categorization decides what kind of failure occurred, the custom payload is the versioned envelope you emit downstream — a fixed contract of field paths, violation kinds, severity, and remediation hints that never drifts across driver or server upgrades.
Operational Mechanics and Write-Path Impact
MongoDB evaluates a validator synchronously during insert, update, and replace. When a document fails, the server returns error code 121 (DocumentValidationFailure) — a code you cannot customize server-side. There is no way to inject an arbitrary error code or payload from inside the validator itself; the only server-side hook is the description keyword, which is echoed back verbatim inside errInfo as a human-readable hint. The custom payload is therefore built at the driver boundary, immediately after the rejection, before the error enters business logic. This keeps payload construction entirely off the success write path — it runs only on the already-rejected failure path — so it adds nothing to healthy write latency.
The whole point of the payload is to decouple your downstream contract from MongoDB’s internal shape. The raw errInfo.details.schemaRulesNotSatisfied array names the failing rule via operatorName, but its structure differs across server versions, and BSON scalars like ObjectId and Decimal128 are not JSON-serializable. The table below is the remapping contract this playbook enforces:
| Raw server signal | Stable payload field | Why it is remapped |
|---|---|---|
code: 121 (always) |
error_type: "schema_violation" |
A bare 121 carries no routing intent; a named type lets consumers branch without a lookup table. |
operatorName: "required" |
kind: "missing_field" |
Operator names are MongoDB jargon; the payload speaks in consumer-facing violation kinds. |
operatorName: "bsonType" | "enum" | "pattern" |
kind: "type_mismatch" | "value_not_allowed" | "format_mismatch" |
Collapses driver-version wording into a fixed enum downstream code can switch on. |
consideredValue (BSON scalar) |
observed (JSON-safe) |
ObjectId/Decimal128/datetime raise TypeError under json.dumps; they are coerced to strings. |
| absent | contract_version |
Pins the envelope shape so consumers can detect and adapt to payload changes. |
| absent | schema_version |
Ties every violation to the validator revision that produced it, enabling rollback analysis. |
Because the payload builder is a pure, I/O-free function, it can run synchronously inside an ingestion worker or asynchronously in a telemetry processor without changing behavior — the same property that lets collection-level validators and error categorization share one code path. One hard constraint: dynamic interpolation of the actual violating value is only partially available. On MongoDB 5.0+, consideredValue carries the offending scalar for type failures, but it is absent for required and $expr rules, so treat observed as best-effort enrichment, never a guaranteed field.
Exact Diagnostic Fingerprints and Fast Resolution
Every $jsonSchema rejection surfaces as WriteError with code: 121 and errmsg: "Document failed validation". In PyMongo the structured tree lives at exc.details["errInfo"]["details"]["schemaRulesNotSatisfied"] for a single write, and at bwe.details["writeErrors"][i]["errInfo"] for each failing index of a BulkWriteError. The unambiguous tell that a payload is buildable is the presence of schemaRulesNotSatisfied — if it is missing, you are on a pre-5.0 server or looking at a non-validation error such as 11000 (duplicate key) or 13 (auth).
Inspect the exact tree your builder must handle before writing a single line of parser:
// mongosh: provoke a violation and print the tree the payload is built from.
try {
db.user_events.insertOne({ event_type: "click" }); // missing user_id, timestamp
} catch (e) {
printjson(e.errInfo.details.schemaRulesNotSatisfied);
}
Once payloads are flowing to your sink, these are the fingerprints incident responders match on, mapped to the payload field they read and the first triage action:
| Fingerprint | Payload field | Severity | First action |
|---|---|---|---|
kind: "missing_field" on a required key |
violations[].field |
P1 | Block the upstream producer; correlate with the last client deploy. |
kind: "value_not_allowed" / format_mismatch |
violations[].operator |
P2 | Route to DLQ; run a type-cast or enum-mapping remediation job. |
kind: "unknown" in the payload |
contract_version |
P2 | Validator gained an operator the map lacks; extend VIOLATION_KIND. |
error_code: 121 but empty violations |
error_type |
P3 | Pre-5.0 server or truncated errInfo; fall back to log parsing. |
# mongod JSON log (4.4+): which operators are driving rejections this hour?
grep '"code":121' /var/log/mongodb/mongod.log \
| jq -r '.attr.errInfo.details.schemaRulesNotSatisfied[].operatorName' | sort | uniq -c
# Application sink: find violation kinds that fell through to "unknown".
jq 'select(.violations[].kind=="unknown") | .violations[].operator' payloads.ndjson | sort | uniq -c
Step-by-Step Playbook
This builds a payload emitter you can drop into any PyMongo ingestion path. It intercepts code: 121 at the driver boundary, flattens the rule tree, remaps to the stable contract, and returns a JSON-safe envelope.
-
Pin the contract and the operator map. Version the envelope explicitly so consumers can detect shape changes, and encode every operator you expect into a constant — anything unmapped becomes
"unknown", which is itself a routable drift signal:from datetime import datetime, timezone from typing import Any, Dict, List, Optional from bson import ObjectId, Decimal128 from pymongo.errors import WriteError PAYLOAD_CONTRACT_VERSION = "1.2.0" VIOLATION_KIND = { "required": "missing_field", "additionalProperties": "unexpected_field", "bsonType": "type_mismatch", "enum": "value_not_allowed", "pattern": "format_mismatch", "minimum": "below_minimum", "maximum": "above_maximum", "minLength": "too_short", "maxLength": "too_long", } -
Coerce BSON scalars to JSON-safe values. A queue or HTTP router serializes the payload with
json.dumps, which raisesTypeErroronObjectId,Decimal128, anddatetime. Normalize them at extraction time so the payload never dies in transit:def _json_safe(value: Any) -> Any: if isinstance(value, ObjectId): return str(value) if isinstance(value, Decimal128): return str(value.to_decimal()) if isinstance(value, datetime): return value.isoformat() return value -
Flatten
schemaRulesNotSatisfiedinto leaf violations.requiredfailures list bare names undermissingProperties; property-level failures nest one level deeper underpropertiesNotSatisfied[].details[]. Expand both into a flat list of{field, kind, operator}records:def _violations(err_info: Optional[Dict[str, Any]]) -> List[Dict[str, Any]]: rules = (err_info or {}).get("details", {}).get("schemaRulesNotSatisfied", []) out: List[Dict[str, Any]] = [] for rule in rules: for name in rule.get("missingProperties", []): out.append({"field": name, "kind": "missing_field", "operator": "required"}) for prop in rule.get("propertiesNotSatisfied", []): field = prop.get("propertyName", "unknown") for inner in prop.get("details", [{}]) or [{}]: op = inner.get("operatorName", rule.get("operatorName", "unknown")) out.append({ "field": field, "kind": VIOLATION_KIND.get(op, "unknown"), "operator": op, "observed": _json_safe(inner.get("consideredValue")), }) return out -
Assemble the versioned envelope. Wrap the violations with the routing metadata downstream consumers depend on — collection, schema version, document id, severity, and timestamp:
def build_error_payload(err_info, collection, schema_version, doc_id=None): violations = _violations(err_info) return { "contract_version": PAYLOAD_CONTRACT_VERSION, "error_code": 121, "error_type": "schema_violation", "collection": collection, "schema_version": schema_version, "document_id": _json_safe(doc_id) if doc_id is not None else None, "violations": violations, "severity": "reject" if violations else "unknown", "occurred_at": datetime.now(timezone.utc).isoformat(), } -
Emit at the driver boundary. Catch
WriteError, build the payload only forcode == 121, and re-raise everything else so network and auth failures are never mislabeled as schema violations:def insert_with_payload(collection, document, schema_version): try: collection.insert_one(document) return {"status": "ok"} except WriteError as exc: if exc.code != 121: raise payload = build_error_payload( exc.details.get("errInfo"), collection.name, schema_version, document.get("_id"), ) return {"status": "rejected", "payload": payload}Expected output on a document missing a required
user_id:{"status": "rejected", "payload": { "contract_version": "1.2.0", "error_code": 121, "error_type": "schema_violation", "collection": "user_events", "schema_version": "2024-06-01", "document_id": "665f...", "severity": "reject", "violations": [{"field": "user_id", "kind": "missing_field", "operator": "required"}], "occurred_at": "2026-07-03T12:00:00+00:00"}}
For bulk writes, iterate bwe.details["writeErrors"] and call build_error_payload per failing index — the wider PyMongo surface for that lives under Python integration for schema checks, and the emitted payloads feed the async validation monitoring dashboards that plot violation kinds over time.
Failure Modes & Rollback
Payload construction is code-side and never mutates the database, so rollback is always a redeploy — but each step has a distinct way to go wrong:
TypeErrorat serialization. Forgetting the_json_safecoercion means a payload containing anObjectIddocument_idcrashesjson.dumpsand the router silently drops it. Recovery: route every scalar through_json_safe; time-to-recover is one redeploy. Retain the rawerrInfoalongside the derived payload so dropped records can be rebuilt.kind: "unknown"flood. Tightening a validator with a new operator (for exampleminItems) before extendingVIOLATION_KINDsends a wave ofunknownviolations downstream. Recovery: add the operator to the map and redeploy; because the builder is pure, you can re-run it over retained raw payloads to re-label history — a zero-data-loss rollback.- Mislabeling non-validation errors. Dropping the
exc.code != 121guard turns duplicate-key (11000) and auth (13) failures into phantom schema violations. Recovery: keep the guard; re-raise anything that is not121. - Empty
violationson old servers. On MongoDB 4.2–4.4 the tree is shallow and may omitoperatorName, yielding an empty list. This is correct degradation, not a bug — fall back tomongodlog parsing rather than trusting a bare envelope.
If a bad contract version ships and a consumer chokes on it, there is no server-side change to revert. Roll the emitter back to the previous PAYLOAD_CONTRACT_VERSION, redeploy the worker, and — because enforcement and payload construction are independent — write acceptance is entirely unaffected. Time-to-recover equals your deploy cycle, typically seconds to a couple of minutes. Documents rejected while the bad version was live can be re-emitted from their retained errInfo.
Frequently Asked Questions
Can I make MongoDB return a custom error code instead of 121?
No. Every $jsonSchema violation surfaces as WriteError code 121 (DocumentValidationFailure), and there is no server-side hook to change it or inject an arbitrary payload from inside the validator. The only in-schema lever is the description keyword, which is echoed back in errInfo as a human-readable hint. Branch your handling on the rule names inside errInfo and build the custom payload at the driver boundary, never on a bespoke code.
How do I keep the payload stable when the errInfo shape changes across MongoDB versions?
Stamp every envelope with a contract_version and drive the mapping from a constant operator-to-kind table rather than from raw errmsg strings. When the server tree changes, only the flattening code adapts; the downstream contract stays fixed. Unmapped operators fall through to kind: "unknown", which is a deliberate drift signal telling you to extend the map — the payload shape consumers see never breaks.
Why coerce ObjectId and Decimal128 explicitly instead of letting json.dumps handle them?
Because it cannot. The default JSON encoder raises TypeError on ObjectId, Decimal128, and datetime, and inside a message-queue producer or HTTP router that exception usually means the payload is silently dropped. Coercing every BSON scalar to a string or ISO timestamp at extraction time guarantees the payload survives serialization end to end.
Related
- Categorizing schema validation errors — the parent workflow that classifies a code-121 rejection before this payload standardizes its downstream shape.
- Automated Schema Enforcement & Monitoring — the overarching framework spanning collection validators, middleware, and pipeline pre-flight checks.
- Graceful degradation for legacy document formats — where a rejected document is routed and reconciled once its payload is built.
- Setting up validationAction warn vs error in production — why
warnmode emits no driver error, so no payload is built until enforcement iserror. - Async validation monitoring dashboards — the sink that plots emitted violation kinds and severities over time.