DEV Community

AI Dev Hub
AI Dev Hub

Posted on

Zod vs Pydantic vs Ajv: I ran one broken schema in all 3

Zod vs Pydantic vs Ajv: I ran one broken schema in all 3

Ajv. It was the only one of the three that rejected my malformed tool schema, because it validates the schema document itself rather than validating data against it. Zod prevents the bug by construction if you author in TypeScript, and Pydantic has by far the best runtime error messages. None of them knew anything about provider-specific rules, which is where my actual bad afternoon came from.

Disclosure first: the structured output validator I link to below is one I built. I got there after running an Anthropic tool definition through four generic JSON Schema tools and finding that not one of them knew input_schema from parameters. It's free, runs client side, no signup, nothing gets uploaded anywhere. If you know a better one, tell me and I'll link that instead.

The task: one tool definition, two different questions

On Tuesday, August 18, 2026, an invoice extraction agent I maintain booked a credit note as a charge. A customer watched $91.64 land on the wrong side of their ledger. Out of 1,247 extraction calls that week, 3 came back with a negative total, and the field I was certain had been guarding against exactly that looked like this:

"total": { "type": "number", "exclusiveMinimum": "0" }

The zero is a string. JSON Schema says exclusiveMinimum takes a number, so what I shipped was a keyword with an invalid value, which most runtimes quietly skip over. It sat there for 11 days.

Once I stopped being annoyed at myself, I noticed I'd been collapsing two questions into one. Question one: is this schema document legal, and legal for the endpoint I'm posting it to? Question two: does the JSON the model sent back match it? I had only ever automated the second one.

So I took the same broken tool definition and pushed it through the three validators I reach for most: Ajv 8.17 on Node 22, Zod 4, and Pydantic 2.11. Same schema, same sample payload (a credit note with total: -91.64), same question each time. Does anything warn me before this reaches production?

Ajv: the only one that read the schema as a document

Ajv does something the other two don't. Before it looks at any data, it compiles the schema, and by default it validates that schema against the JSON Schema meta-schema. Your schema is data too. That's the whole trick, and it's why Ajv was the only tool here that said a word.

// npm i ajv@8
// node validate-tool-schema.mjs
import Ajv2020 from "ajv/dist/2020.js";

const inputSchema = {
  type: "object",
  properties: {
    invoice_id: { type: "string" },
    line_items: {
      type: "array",
      items: {
        type: "object",
        properties: {
          sku: { type: "string" },
          qty: { type: "integer", minimum: 1 }
        },
        required: ["sku", "qty"],
        additionalProperties: false
      }
    },
    total: { type: "number", exclusiveMinimum: "0" }
  },
  required: ["invoice_id", "line_items", "total"],
  additionalProperties: false
};

const ajv = new Ajv2020({ strict: true, allErrors: true });

try {
  const validate = ajv.compile(inputSchema);
  const ok = validate({ invoice_id: "INV-4471", line_items: [], total: -91.64 });
  console.log(ok ? "payload ok" : validate.errors);
} catch (err) {
  console.error("schema rejected:", err.message);
}
Enter fullscreen mode Exit fullscreen mode

Output: schema rejected: schema is invalid: data/properties/total/exclusiveMinimum must be number. Four minutes from npm i to that line. Had those four minutes existed as a pre-commit hook back in July, the credit note would have bounced.

Ajv's data errors are less pleasant. You get instancePath: "/line_items/0/qty" and message: "must be >= 1", precise and joyless, and turning that into something a model can act on is your job. strict: true also gets opinionated about unknown keywords, which flagged two example fields I'd copied straight out of a docs page. Mildly irritating. Technically correct.

Zod: the bug can't happen, until you need the other direction

You cannot write exclusiveMinimum: "0" in Zod. There's no syntax for it. You write z.number().positive() and the broken version simply isn't expressible, so a whole category of typo stops existing. Since March 2026 every TypeScript agent I've started defines its tools in Zod first and emits JSON Schema with z.toJSONSchema(), and I haven't hand-written a tool schema in that codebase since.

The trouble starts when the schema isn't yours. Most of the tool definitions I deal with now arrive from somewhere else: an MCP server's inputSchema, a partner's OpenAPI fragment, something a coworker generated with a model at 2am. To check those with Zod you need a converter, and converters are lossy in the direction that hurts. I ran the broken schema through json-schema-to-zod and got back z.number(). Clean. No warning. The bad keyword had been dropped on the floor, and the resulting type happily accepted -91.64.

I don't know whether that's a deliberate be-generous-with-input choice or just unimplemented. Either way, silently discarding a keyword is precisely the failure I was hunting, so Zod scored zero on question one through no real fault of its own. Wrong layer for the job.

One wrinkle to plan for: z.toJSONSchema() factors reused sub-schemas into $defs with $ref pointers. Perfectly legal. Not universally loved by strict function-calling modes, so I inline them before anything goes over the wire.

Pydantic: the error messages I actually wanted

Pydantic loses question one outright. It doesn't ingest foreign JSON Schema at all. model_json_schema() is a one-way street, and checking a handwritten document in Python means reaching for jsonschema.Draft202012Validator.check_schema(), which is a different library entirely.

On question two it wins, and it isn't close. A ValidationError hands you loc, msg, type, and input for every failure, and that's the only validator output I've managed to serialize and pass straight back to the model as a repair message with results I trust. Last month 44 responses failed validation in that pipeline and 41 were fixed on the first retry, after I started sending Pydantic's errors() list verbatim instead of my own tidy summary string. I never measured the before number, which I do regret.

Its generated schemas deserve a look before you send them. Optional fields come out as anyOf: [{...}, {"type": "null"}] and nested models land in $defs. Both correct. Both have been rejected by a strict mode at least once in my experience, usually around 4pm on a Friday.

Scores, and the one I'd actually use

Criterion Ajv 8.17 Zod 4 Pydantic 2.11
Caught the malformed exclusiveMinimum yes, at compile time n/a, can't express it no, won't read the schema
Data error precision high, phrased for machines compile time only high, phrased for humans
Errors good enough to feed back to a model no no yes
Knows provider tool-schema rules no no no
Time to first useful error 4 minutes 20+ minutes (rewrite required) 6 minutes
Language JS/TS TS Python

The split I've settled on isn't a single tool. In Node, author in Zod and emit with z.toJSONSchema(). Then push the emitted document through Ajv's compile() inside a test, which proves the thing is still legal after conversion. About 15 lines of test code total. In Python, check_schema() on anything handwritten and Pydantic on everything the model returns, with .errors() going directly into the retry prompt.

There's a fourth check none of the three perform. A schema can be flawless JSON Schema and still be wrong for the endpoint you're posting it to. Anthropic wants it under input_schema, OpenAI's function tools want it under parameters, and MCP spells the key inputSchema in camelCase. Same object, three different homes. Strict mode piles on more: every property has to be listed in required, and additionalProperties: false stops being optional. That gap is what I built the structured output validator to close, because I got tired of learning about it from a 400 response.

If you're only installing one thing today, install Ajv. It answers the question the other two structurally cannot, and it costs four minutes.

FAQ

Q: Can't I just send the schema and let the API reject it?
A: Partly. Both Anthropic and OpenAI refuse some malformed definitions at request time, and strict mode refuses more. A keyword with a wrong value type is the case that has slipped past me. Valid-enough JSON to accept, meaningless enough to ignore. And a 400 in staging is a much slower loop than a red test.

Q: Does Ajv catch every schema mistake?
A: No. It catches illegal JSON Schema. It has nothing to say about a schema that's legal and wrong, like qty typed as a string, or a required field the model has never once produced. Sample payloads and evals cover that.

Q: Zod or Pydantic for a new agent in 2026?
A: Whichever language the rest of your service already speaks. Genuinely. I watched a team stand up a Python sidecar purely to get Pydantic errors, and the deploy complexity cost more than the errors were worth.

Q: Doesn't constrained decoding make this moot?
A: It removes parse failures, which is most of the day-to-day pain. It can't tell you your schema encodes the wrong rule. Constrained decoding against exclusiveMinimum: "0" produces beautifully formatted negative totals.

Written with AI assistance and human review. Try the tool at aidevhub.io/structured-output-validator.

Top comments (0)