Pydantic, Zod and Typed LLM Outputs
5 min read · updated August 3, 2026
The reason to define the shape in Pydantic or Zod rather than in raw JSON Schema is not ergonomics. It is that the schema you send, the validator you run and the type your editor knows about become the same object, and cannot drift apart in a way nothing notices.
One definition, three jobs
class Invoice(BaseModel) / const Invoice = z.object({...})
|
+-------------+-------------+
| | |
JSON Schema validator static type
(the request) (the parse) (your editor)Written by hand, those three are three files that agree today. Derived from one definition, disagreement is impossible — which matters most at the moment somebody adds a field, because the schema, the parser and the type all change together or the build fails.
The catch is the middle of that diagram. Neither library emits JSON Schema in the dialect a strict mode wants, and the differences are exactly the ones that produce a 400.
Python: Pydantic
from typing import Literal
from pydantic import BaseModel, Field, field_validator
class Invoice(BaseModel):
reasoning: str = Field(description="Where on the page you found these. Two sentences.")
invoice_number: str
total_including_tax: float = Field(description="Grand total, not the subtotal.")
currency: Literal["EUR", "GBP", "USD", "OTHER"]
customer_name: str | None = Field(description="null if the document names no customer.")
@field_validator("total_including_tax")
@classmethod
def sane(cls, v: float) -> float:
if v < 0 or v > 10_000_000:
raise ValueError("total outside plausible range")
return v
schema = Invoice.model_json_schema()Three things about what model_json_schema() gives you. Nested models become $defs plus $ref, which strict modes accept. str | None becomes an anyOf of {"type":"string"} and {"type":"null"} rather than the type-array form — usually fine, worth knowing when an error message points at an anyOf you did not write. And a field with a default becomes optional and gains a default key, which is the most common single cause of a strict-mode rejection: a default is a Python concept and the request schema has nowhere to put it.
Note the reasoning field first, and the validator. The field order in the class is the field order in the schema is the generation order. The validator runs on your side afterwards and enforces a numeric range that a strict schema cannot express at all.
The strict-schema walker
This is the missing step. It walks the generated schema and applies the three transformations strict modes require, including inside $defs, which is where hand-written fixes always miss one:
# Drop keywords hosted strict modes do not accept, force every property
# required, and forbid extra properties on every object -- everywhere.
DROP = {
"default", "examples", "$comment", "readOnly", "writeOnly", "deprecated",
"minLength", "maxLength", "pattern", "format",
"minimum", "maximum", "exclusiveMinimum", "exclusiveMaximum", "multipleOf",
"minItems", "maxItems", "uniqueItems",
"minProperties", "maxProperties", "patternProperties",
}
def strictify(node):
if isinstance(node, list):
return [strictify(n) for n in node]
if not isinstance(node, dict):
return node
out = {k: strictify(v) for k, v in node.items() if k not in DROP}
if out.get("type") == "object" or "properties" in out:
props = out.setdefault("properties", {})
out["additionalProperties"] = False
out["required"] = list(props.keys()) # every property, no exceptions
return out
schema = strictify(Invoice.model_json_schema())Two warnings. Forcing every property into required is correct only if every optional field is genuinely nullable in your model — run it over a model with a non-nullable defaulted field and you have told the API a field is required that your own type says can be absent, and the mismatch shows up as a validation error on a response that was fine. Fix the model, not the walker. Second, DROP is a snapshot; the supported keyword sets differ per provider and change. Check yours rather than trusting this list.
Everything in DROP that carried meaning — a range, a pattern, a minimum length — has to go somewhere. It goes in the field description for the model and in a validator for you. Deleting it from the schema is not deleting the requirement.
Worth stating explicitly, because it is easy to lose sight of once the walker is written: the type definition remains the strict one. Your Pydantic model or Zod object still carries the range check, the pattern and the length bound, and still enforces them when the response comes back. strictify produces a lossy projection of that definition for the wire, and only for the wire. If you find yourself weakening the model so that the generated schema passes, you have the dependency the wrong way round — the request schema is derived from your types, not the other way about.
TypeScript: Zod
import { z } from "zod";
export const Invoice = z.object({
reasoning: z.string().describe("Where on the page you found these."),
invoice_number: z.string(),
total_including_tax: z.number().describe("Grand total, not the subtotal."),
currency: z.enum(["EUR", "GBP", "USD", "OTHER"]),
customer_name: z.string().nullable().describe("null if none is named."),
});
export type Invoice = z.infer<typeof Invoice>; // the static type, free
const parsed = Invoice.safeParse(JSON.parse(raw));
if (!parsed.success) {
// parsed.error.issues[] has { path, code, message } -- the path is what
// you put in a repair prompt.
return handleInvalid(parsed.error.issues);
}
const invoice: Invoice = parsed.data; // typed from here downUse .nullable(), never .optional(), for the same reason as in Python: optional produces a key that may be absent, and strict modes will not accept it. Prefer the SDK helper your provider ships for converting the Zod object into a request schema over a generic converter, since the helper tracks the dialect the API currently wants.
Two library features are worth reaching for deliberately rather than discovering later. Both ecosystems have a discriminated-union construct — Zod’s z.discriminatedUnion, and a Field(discriminator=...) union in Pydantic — and both compile to the tagged anyOf with a const that constrained decoding handles best, so using them gets you the right schema shape without hand-writing it. And both let you attach arbitrary keys to the emitted schema: describe() in Zod, Field(description=...) or json_schema_extra in Pydantic. Since the description is the part of the schema the model reads most carefully, keeping it on the type definition means the instruction and the validation rule are edited in the same place — which is the entire argument for this approach in one sentence.
Three places the chain leaks
- Validation is not verification.
parsed.datais typedInvoice. It is not therefore a correct invoice. Static types describe shape; every semantic check — totals adding up, dates in range, quotes present in the source — is a validator you write. Put them in the model, where they travel with the type. - The parse boundary is a real boundary. Type assertions do nothing at run time.
JSON.parse(raw) as Invoiceis a lie the compiler cannot catch and the most common way typed pipelines end up with untyped data flowing through them. AlwayssafeParse. In Python, alwaysmodel_validate, never a barecast. - Every response can still be a refusal or a truncation. Both are well-formed HTTP 200s. Check
finish_reasonand any refusal field before you reach the parser, or those arrive as confusing validation errors far from their cause.