validationAction error vs warn: Performance Impact Benchmarks

Choosing between validationAction: "error" and validationAction: "warn" is usually framed as a safety decision, but it is also a measurable performance decision, and this page benchmarks exactly what each mode costs on the write path. It sits under strict vs moderate validation levels within the broader MongoDB JSON Schema Validation Architecture, and the reader outcome is a repeatable methodology plus a defensible mental model: both modes pay an identical schema-evaluation CPU tax on every single write, so the throughput gap between them is not the check itself but what happens after it — error rejects and stops, while warn persists the document and writes an extra diagnostic log line. If you expect warn to be the “cheaper” mode, the numbers below will surprise you.

Operational Mechanics and Write-Path Impact

The critical fact that shapes every benchmark result is that validationAction does not gate whether the $jsonSchema is evaluated — it only gates what MongoDB does with the verdict. When any validator is attached, the storage engine parses the incoming BSON and walks the schema keyword tree on every insert, update, replace, and findAndModify, and that traversal is the dominant CPU term in both modes. The action dial then diverges only on the failure branch:

  • error rejects a failing write, returns WriteError code 121, and performs no persistence for that document. The rejected write does zero storage I/O, so a workload with many violations under error can actually finish faster than a clean one, because rejected documents never touch the journal or indexes.
  • warn always persists the document — valid or not — and, on a failing write, additionally emits a warning line to the mongod diagnostic log (component STORAGE, message id 20294). That is a second, asynchronous write path: extra serialization, extra log I/O, and, crucially, a non-conforming document now durably stored in the collection.

So the honest comparison is not “fast validation versus slow validation.” It is “same evaluation cost, different tail.” The table below fixes the five dimensions that actually differ, evaluated per write:

Mode Schema-eval CPU (per write) Persistence on failure Extra log I/O on failure Data correctness Right use
Validator off None N/A None Not enforced Baseline measurement only
warn Full traversal Document is written One mongod log line per failure Non-conforming data accumulates Time-boxed observation before enforcing
error Full traversal Write is rejected (no persist) None Guaranteed conforming Steady-state production enforcement

The single largest lever on the shared CPU term is schema shape, not the action. Deeply nested allOf chains multiply the number of subschemas evaluated per document, and an unanchored pattern regex (one without ^/$) forces the engine to attempt a match at every offset in the string, which turns a cheap field check into a super-linear scan on long values. Two collections with the same warn/error setting can differ by an order of magnitude in write cost purely because one schema uses tight, anchored constraints and the other buries a catch-all allOf of unanchored regexes. Benchmark the schema you will actually ship, on representative document sizes — a synthetic three-field schema tells you nothing about a 40-field contract with nested array validation.

Exact Diagnostic Fingerprints and Fast Resolution

When a benchmark or a production dashboard shows an unexpected write-latency profile, these are the signatures that tell you which mode is really active and where the cost is going:

Signature Root cause Resolution
P95 insert latency rises the moment the validator is attached, before any failures Shared schema-eval CPU tax is now on every write Expected; tune schema shape, not the action
mongod log fills with id 20294 warning lines under load warn is active and a large fraction of writes violate the schema Remediate the source, then promote to error per the rollout below
Collection document count keeps rising but conforming-count does not warn is silently persisting non-conforming documents Switch to error once drift is remediated; warn is not a steady state
Rejected-write throughput exceeds clean-write throughput under error Rejected writes skip persistence and index maintenance Expected artifact; measure clean-path P95 separately

Confirm which action is live before trusting any number — the collection catalog is ground truth, not the deploy script:

from pymongo import MongoClient

db = MongoClient("mongodb://primary:27017").bench_db
opts = db.command("listCollections", filter={"name": "orders"})["cursor"]["firstBatch"][0]["options"]
print(opts.get("validationLevel"), opts.get("validationAction"))
# => strict warn   (or None None if no validator is attached)

Step-by-Step Playbook

To produce numbers you can defend, measure three configurations — validator off, warn, and error — against the same document generator on the same hardware, and report throughput and P95 latency rather than a single mean. Treat the figures the harness prints as illustrative of your environment only; publish your own measured numbers, never borrowed ones.

Per-write cost of validator off, warn, and error Three lanes compare the write path. With the validator off there is no schema-eval cost, the document persists, and there is no side-effect. Under warn the full schema-eval CPU cost is paid on every write, the document persists even when it fails, and a failure adds a mongod log line while non-conforming data is kept. Under error the same full schema-eval CPU cost is paid, a valid document persists while an invalid one is rejected with WriteError code 121, and no non-conforming data is kept. The shared schema-eval column shows that warn and error pay the identical CPU tax; only the outcome and side-effect differ. Mode Schema-eval CPU Outcome Side-effect off skipped Persist none warn full traversal paid every write Persist anyway valid or not log id 20294 bad data kept error full traversal paid every write valid → persist invalid → reject 121 no bad data no log line warn and error pay the identical schema-eval CPU tax — only the outcome and side-effect differ

1. Fix the workload. Generate a deterministic stream of documents with a known valid/invalid mix so every run is comparable. Keep the shape identical to production, including the field that will violate the schema:

import random

def make_doc(i: int, invalid_rate: float = 0.1) -> dict:
    bad = random.random() < invalid_rate
    return {
        "tenant_id": (12345 if bad else "acme"),   # int is invalid; str is valid
        "status": ("open" if not bad else "??"),
        "amount": round(random.uniform(1, 5000), 2),
        "created_at": i,
    }

2. Define the three configurations. Attach the same $jsonSchema for the warn and error runs, and detach it entirely for the baseline. Anchor every pattern so the regex cost is honest:

SCHEMA = {
    "bsonType": "object",
    "required": ["tenant_id", "status", "amount"],
    "properties": {
        "tenant_id": {"bsonType": "string", "pattern": "^[a-z0-9_]{2,32}$"},
        "status": {"bsonType": "string", "enum": ["open", "settled", "void"]},
        "amount": {"bsonType": "double", "minimum": 0},
    },
}

def configure(db, action):
    if action is None:
        db.command("collMod", "bench", validator={}, validationLevel="off")
    else:
        db.command("collMod", "bench", validator={"$jsonSchema": SCHEMA},
                   validationLevel="strict", validationAction=action)

3. Run the timed harness. Measure per-write latency, then report throughput and P95 — a mean hides the tail that validation actually moves:

import time
from statistics import quantiles
from pymongo import MongoClient
from pymongo.errors import WriteError

def bench(action, n=20000):
    db = MongoClient("mongodb://primary:27017").bench_db
    db.bench.drop()
    db.create_collection("bench")
    configure(db, action)
    lat = []
    start = time.perf_counter()
    for i in range(n):
        doc = make_doc(i)
        t0 = time.perf_counter()
        try:
            db.bench.insert_one(doc)
        except WriteError:
            pass  # under error, a rejected write is a real, timed outcome
        lat.append((time.perf_counter() - t0) * 1000)
    elapsed = time.perf_counter() - start
    p95 = quantiles(lat, n=100)[94]
    print(f"{str(action):>5}: {n/elapsed:8.0f} writes/s  P95={p95:6.3f} ms")

for action in (None, "warn", "error"):
    bench(action)

4. Read the shape, not the absolute numbers. Illustrative output from one run on a single-node replica set — your figures will differ, so gather your own:

 None:    18400 writes/s  P95= 0.071 ms   # no schema-eval cost
 warn:    14200 writes/s  P95= 0.104 ms   # full eval + persists + logs failures
error:    15100 writes/s  P95= 0.096 ms   # full eval, rejected writes skip persistence

The takeaway repeats across environments: the big step is off → validator-attached (the schema-eval tax), while warn versus error is a smaller, mix-dependent difference where error often edges ahead precisely because rejected writes do no storage work. The deeper rationale for choosing between the two in a live system, beyond raw throughput, is laid out in setting up validationAction warn vs error in production.

Failure Modes & Rollback

Benchmarks and rollouts fail in predictable ways; each has a bounded recovery:

  • Measuring warn on a dirty collection. If the collection already holds non-conforming documents, warn keeps adding more during the run and your correctness numbers become meaningless. Reset with a fresh collection between runs; recovery is a drop plus create_collection.
  • Regex cost swamps the signal. An unanchored pattern makes both warn and error look catastrophically slow and hides the action comparison. Anchor every regex with ^/$, re-run, and the eval column drops sharply — time-to-recover is one schema edit.
  • warn left on in production after the study. The most expensive failure is not latency but silent data drift: a collection parked in warn accumulates malformed documents indefinitely. Promote to error the moment drift is remediated.

Reverting any configuration is a single metadata-only collMod, effective on the primary immediately and propagated through the oplog within replication lag:

// Stop enforcing but keep the schema attached for telemetry.
db.runCommand({ collMod: "orders", validationLevel: "moderate", validationAction: "warn" })

// Remove the validator entirely (restores the baseline write path).
db.runCommand({ collMod: "orders", validator: {}, validationLevel: "off" })

Frequently Asked Questions

Is warn faster than error because it does not reject writes?

No — that is the common misconception this page corrects. Both actions run the full $jsonSchema evaluation on every write, so they share the identical CPU cost. On the failure branch, error rejects and does no persistence, while warn persists the document and writes an extra mongod log line. In workloads with a meaningful violation rate, error often measures slightly faster because rejected writes skip journal, index, and log I/O entirely.

What dominates the schema-evaluation cost that both modes pay?

Schema shape, not action. Deeply nested allOf chains multiply the number of subschemas evaluated per document, and an unanchored pattern regex forces a match attempt at every character offset, turning a field check into a super-linear scan on long strings. Anchor every regex with the start and end tokens and flatten redundant combinators before you conclude that validation itself is expensive.

Can I trust published benchmark numbers for my deployment?

Treat all such numbers, including the illustrative figures here, as directional only. Absolute throughput depends on document size, schema complexity, hardware, write concurrency, and replication configuration. Run the harness against your own schema and representative documents on your own hardware, and report throughput plus P95 latency rather than a single mean, which hides the tail that validation actually shifts.