AI agents

From JSON Schema to Pydantic: One Source of Truth

Antonio Evans9 min read

A split workbench seen from above: on the left a single blue document labelled with a curly-brace icon; a thin line runs from it to two identical stamped copies.
Key points

Key points

  1. 1Define the output contract once as a Pydantic model, generate the JSON Schema from it with model_json_schema, and hand that single artifact to the model.
  2. 2A second, hand-edited schema for the prompt drifts from the validator, and the failure is silent because the model keeps returning plausible fields your code then rejects.
  3. 3Pydantic's default schema output uses constructs that strict provider modes reject (nullable unions, numeric bounds, references), so a small cleaning pass belongs in code.
  4. 4Commit a serialized copy of the generated schema and a CI test that fails when it changes, so a field edit cannot silently alter the model-facing contract.

Why two schemas drift

The common pattern looks harmless. A developer writes a JSON Schema by hand for the tool definition or the structured output request, usually copied from docs or from a JSON example. Separately, someone writes a Pydantic model for the same shape to validate replies. Both describe the same object, so they ought to match.

They do not stay matched. The validator is the one that gets edited, because it lives next to the code that consumes the field. Adding an optional field, renaming one, or tightening a type is a one-line change in the class. The schema string in the prompt or the tool definition is in a different file, often a different repository, and nothing breaks when it is left alone. The model is now instructed to produce a shape the application will reject.

The failure mode is quiet. Validation errors on a small fraction of calls, retries that appear to succeed because the retried prompt happens to omit the new field, and downstream code that treats a missing value as empty. Nothing throws a schema mismatch error, because the two schemas were never compared.

Pydantic exists precisely to avoid this duplication in ordinary code. As the Pydantic guide to LLM use puts it, unlike dataclasses, Pydantic defines a schema for your class, and that schema is used to validate data and to generate a JSON schema per the Pydantic LLM article. The schema and the validator come from the same source by design. The drift only appears when someone bypasses that and writes the prompt-side schema by hand.

Generating the schema from the class

The mechanism is one method call. BaseModel.model_json_schema returns a JSON-able dict of the model's schema, and the same class validates incoming data per the Pydantic JSON Schema docs. The docs also note the generated schemas are compliant with JSON Schema Draft 2020-12 and OpenAPI v3.1.0, which matters when a provider documents which draft it accepts.

A minimal working example, tool-calling style, adapted from the pattern in the Pydantic LLM guide from the same article:

from typing import Literal
from pydantic import BaseModel, Field

class Ticket(BaseModel):
    """A support ticket extracted from a customer message."""
    summary: str = Field(description="One sentence, under 140 characters.")
    severity: Literal["low", "medium", "high"]
    customer_id: str = Field(description="Account id, format acct_XXXX.")

def tool_definition() -> dict:
    return {
        "type": "function",
        "function": {
            "name": "record_ticket",
            "description": "Record a structured support ticket.",
            "parameters": Ticket.model_json_schema(),
        },
    }

The model sees exactly the schema the validator will enforce, because there is only one schema. A tight vocabulary helps too: an enum of three severities is easier to satisfy than free text plus a later cleanup step. The nuric blog on structured responses makes the general point that the simpler the schema, the more reliable the output, and that constraining to a small enum is a common, well-supported case per the nuric JSON Schema post.

Two practical notes on the call. Pass mode to model_json_schema if your consumer expects input-style or output-style schemas, since Pydantic generates slightly different schemas for each, as documented in the Pydantic schema guide again from the Pydantic docs. And put real text in your docstrings and Field descriptions. Those strings land in the schema the model reads, so they are prompt text that happens to be typed.

Cleaning the schema for strict provider modes

Raw Pydantic output is richer than most strict modes accept. Providers that offer a strict or guaranteed-schema sampling mode restrict the token distribution during generation so the output conforms to the schema, which is why a schema the provider cannot compile is rejected up front rather than failing at parse time per ZeroEntropy on constrained decoding. The Microsoft and EPFL benchmark work on structured outputs notes that constrained decoding frameworks have standardized around JSON Schema, but coverage of schema features varies between frameworks per the JSONSchemaBench paper.

A community write-up of the mismatch lists the recurring offenders concretely: optional fields that Pydantic emits as a type array including null, numeric constraints such as minimum and maximum, recursive models that use references, union types expressed as anyOf or oneOf, and missing or empty additionalProperties per the LangChain forum sanitizer thread. That thread is worth reading as a checklist even if you do not use the library it targets.

The OpenAI Agents SDK encodes the same conclusion in its own type. Its function schema dataclass carries a strict_json_schema flag defaulting to true, with a comment that strict mode strongly increases the likelihood of correct JSON input, and the parameters are derived from a Pydantic model rather than hand-written per the OpenAI Agents SDK function schema reference.

A cleaning function that covers the common cases:

import copy

def to_strict(schema: dict) -> dict:
    """Rewrite a Pydantic schema into a conservative, strict-mode shape."""
    s = copy.deepcopy(schema)

    def walk(node):
        if not isinstance(node, dict):
            return node
        # nullable union: {"anyOf": [X, {"type": "null"}]} -> {**X, "nullable": True}
        if "anyOf" in node:
            parts = [p for p in node["anyOf"] if p.get("type") != "null"]
            if len(parts) == 1:
                node.pop("anyOf")
                node.update(walk(parts[0]))
                node["nullable"] = True
        node.pop("minimum", None)
        node.pop("maximum", None)
        node.pop("exclusiveMinimum", None)
        node.pop("exclusiveMaximum", None)
        if node.get("type") == "object":
            node.setdefault("properties", {})
            node["additionalProperties"] = False
            node.setdefault("required", list(node["properties"].keys()))
        for key in ("properties", "$defs"):
            if key in node:
                node[key] = {k: walk(v) for k, v in node[key].items()}
        if "items" in node:
            node["items"] = walk(node["items"])
        return node

    return walk(s)

Two decisions inside that function deserve a rule.

First, moving bounds such as minimum and maximum out of the schema does not erase them. Keep them in the Pydantic class, because validation still runs on the reply, and the provider's grammar just cannot enforce them during generation. The validator becomes the enforcement point for those constraints, which is fine: it is the same class.

Second, making every property required and every object closed is the strictest reading, and it changes behavior. If a field is genuinely optional, decide whether you want the model to always emit it (required plus nullable) or to sometimes omit it (optional, and your Pydantic default handles absence). Write that decision down, because it is the kind of thing that silently flips during a refactor.

Pydantic constructRaw JSON SchemaProblem in strict modesCleaning action
`intNone` fieldanyOf with a null branchUnion forms not accepted
Field(ge=0, le=100)minimum, maximumBounds often unsupportedStrip from model-facing schema, keep in class
Recursive model$ref and $defsReferences sometimes rejectedFlatten or split into smaller calls
`strint`anyOfUnion forms not accepted
Object with no explicit closeadditionalProperties absentValidation errorSet additionalProperties false
Field with a defaultnot in requiredModel may omit itDecide required plus nullable, or accept omission

The general design advice from the nuric post applies before any of this: if a schema is complex or deeply nested, consider dividing it into smaller, simpler schemas and calling the model more than once rather than pushing one large nested object through a grammar per the nuric post on structuring responses.

Passing one artifact and validating the reply

Once the schema is cleaned, pass the same dict everywhere it is needed: the tool definition, the response format, and any prompt that embeds a schema for documentation. Validate with model_validate_json on the arguments string or the message content, exactly as in the Pydantic guide per the Pydantic LLM article.

resp = client.chat.completions.create(
    model=MODEL,
    messages=messages,
    tools=[tool_definition()],
    tool_choice={"type": "function", "function": {"name": "record_ticket"}},
)

raw = resp.choices[0].message.tool_calls[0].function.arguments
try:
    ticket = Ticket.model_validate_json(raw)
except ValidationError as e:
    log_invalid(raw, e)
    raise

The except block is the part most codebases skip, and it is the part that turns a silent failure into a signal. Pydantic already documents that model replies can arrive wrapped in markdown fences or with prose around the JSON, which is why parsing helper methods that expect raw JSON fail per the same Pydantic guide. Constrained decoding removes most of that class of error, but not the semantic ones: a value that satisfies the type and violates your business rules.

That distinction is worth keeping straight for review purposes. A schema-valid object is not a correct object, as one practitioner write-up on schema-based structured outputs puts it: shape is not truth, and a schema-valid customer id or amount can still violate domain rules per the TrueFoundry article on structured outputs. So the pipeline has three layers, and only the first two belong to this article: grammar constrains the form, Pydantic enforces type and local invariants, and business validation checks facts.

Be equally clear about which mechanism you are choosing. Structured outputs constrain the form of a single response, while function calling is a multi-turn flow where the model picks a tool and pauses for your code to run it per the Machine Learning Mastery comparison. Both consume the same JSON Schema, so both benefit from generation, but the validation point differs: with function calling, you validate the argument object before executing anything.

Two more implementation notes that the provider docs and community threads repeat. First, a "strict" flag in a function-calling schema interacts with required fields, and confusion about what it adds over required is one of the most-viewed questions on the OpenAI community forum per the OpenAI community thread on strict and required. Read the semantics of your provider's strict flag before assuming required and strict are the same thing. Second, JSON mode alone guarantees parseable JSON but not schema adherence, which is why practitioners generally recommend schema-constrained structured outputs over JSON mode for anything that feeds downstream code per the Vellum comparison of the three options.

If you call more than one provider, the cleaning step becomes per-provider: a schema the first model accepts may be rejected by the second. Routing several models behind one key and keeping the request shape identical is what Quanta Inference is for per Quanta Inference, but the schema pipeline above stays your responsibility either way.

A CI test that catches schema changes

The point of generating from the class is that the schema becomes a build artifact. Treat it like one: serialize it, commit it, and fail the build when the generated value differs from the committed one. A diff on that file is a review item, because it is the interface the model was trained or prompted to satisfy.

# tests/test_schema.py
import json, pathlib
from myapp.schemas import Ticket, to_strict

GOLDEN = pathlib.Path("contracts/ticket.schema.json")

def test_schema_is_stable():
    current = to_strict(Ticket.model_json_schema())
    if not GOLDEN.exists():
        GOLDEN.write_text(json.dumps(current, indent=2, sort_keys=True))
    expected = json.loads(GOLDEN.read_text())
    assert current == expected, (
        "Generated schema changed. Review contracts/ticket.schema.json and "
        "update any provider-side config that embeds this schema."
    )

A second test catches the case the first one misses: that the committed schema still compiles for your strict mode. If you can call the provider's schema validation or compile the grammar locally, do it; a schema that passes the golden test but fails provider validation is still a broken deploy. The published benchmark and its schema corpus exist because coverage of JSON Schema features varies across frameworks and providers per the JSONSchemaBench paper, and a curated library list is a reasonable place to find a grammar engine you can test against per the Awesome-LLM-Constrained-Decoding list.

A third test, cheap and worth having, validates a handful of stored model replies against the class. Real replies catch constraints your cleaning function mangled, and they document expected edge cases for whoever changes the class next.

CheckWhat it catchesCost
Golden schema comparisonAny change to the model-facing contractMilliseconds, runs in CI
Provider compile or validationSchema the strict mode rejectsOne API call or local compile
Stored reply validationCleaning bugs and type regressionsMilliseconds per fixture
Live smoke test on releaseEnd-to-end drift after a model or provider changeA few real calls

The live smoke test is not a unit test and should not run on every commit. It is the one that catches a provider silently changing which schema features it accepts, which no amount of golden files will reveal.

When the class is the wrong home for a constraint

Generating from the class is the default, not a religion. Two cases argue for keeping something outside it.

The first is a constraint that only exists for the model, not for your data. A description telling the model to prefer short summaries is prompt text; a length bound your application enforces is validation. Putting both in the class conflates them. If the cleaning step strips a constraint the class enforces, as with numeric bounds above, keep the bound in the class and note in the field description that it is enforced after generation.

The second is a schema so large that the model cannot satisfy it in one shot. Splitting into several smaller models, one per call, and composing the results in code keeps each generated schema simple, which matches the divide-and-conquer advice from the nuric post per that JSON Schema post.

There is also a small operational trap worth knowing. If your extraction output is a dictionary keyed by arbitrary strings, a Pydantic model with a single index field and a single text field is the wrong shape for that data, and it shows up as repeated keys in the model's reply rather than as a validation error. That exact confusion appears in a long OpenAI community thread where a translator receives duplicated text keys instead of an indexed map per the OpenAI community thread on Pydantic schema definitions. The fix is a model that states the map explicitly, not a prompt tweak.

For teams doing this across several business functions, the same rule scales: one class per contract, one generated schema per contract, one golden file per contract, and a CI gate on all of them. Domain-specialized work where the output must match a fixed shape is exactly where a retained AI expert earns its keep, handing back a finished, validated artifact rather than raw prose per Quanta Experts.

FAQ

FAQ

What exactly is the "single source of truth" here?

The Pydantic class. You generate the model-facing JSON Schema from it with model_json_schema, and you validate replies with model_validate or model_validate_json on the same class. Nothing else describes the shape, so there is no second copy to fall out of sync per the Pydantic JSON Schema docs.

Why does my schema work with one provider and get rejected by another?

Strict modes accept different subsets of JSON Schema. Pydantic emits a full-featured schema including nullable unions, numeric bounds and references, and several of those are commonly unsupported in strict modes per the LangChain forum thread. Coverage differences across frameworks are documented in the JSONSchemaBench work per that paper.

If the provider enforces the schema, do I still need Pydantic validation?

Yes, for two reasons. Constrained decoding guarantees form, not truth: a schema-valid id or amount can still violate domain rules per the TrueFoundry article. And any constraint your cleaning step strips from the model-facing schema, such as numeric bounds, is enforced only by your validator.

Should I use strict function calling or response_format?

Use response_format style structured output when the model already has everything it needs and the reply ends your pipeline, for example extraction. Use tool calling when the model must choose among actions or when your code needs to run something before replying per the Machine Learning Mastery comparison. Both take the same generated schema.

How do I stop a teammate from changing the contract by accident?

Commit the serialized generated schema and assert equality in CI, so any class edit produces a reviewable diff on the contract file. Add a test that validates real stored replies, and a release-time smoke test against the provider, since provider-side schema support can change without notice per the Pydantic LLM article.

Sources

Sources

  1. How to Use Pydantic for LLMs: Schema, Validation & Prompts
  2. JSON Schema | Pydantic Docs
  3. Structuring LLM Responses with JSON Schemas | nuric Blog
  4. Constrained decoding: forcing LLM output to a grammar - ZeroEntropy
  5. Generating Structured Outputs from Language Models: Benchmark and ...
  6. Add JSON Schema Sanitizer for OpenAI Structured Outputs ...
  7. Function schema - OpenAI Agents SDK
  8. LLM Structured Outputs with JSON Schema
  9. Structured Outputs vs. Function Calling: Which Should Your Agent ...
  10. Difference between Structured Outputs and function calling required
  11. When should I use function calling, structured outputs or JSON mode?
  12. GitHub - Saibo-creator/Awesome-LLM ...
  13. How to define pydantic/JSON schema - API
  14. https://forum.langchain.com/t/feature-code-add-json-schema-sanitizer-for-openai-structured-outputs-compatibility/1158
About the author

About the author

Antonio Evans
Antonio Evans

Antonio Evans is the founder of Quanta Intelligence and a fractional CTO who builds HIPAA-ready AI systems for health and life-science companies. Before that he was technical co-founder of FriendsEAT for twenty years, CEO of Socialdraft, and a data engineer at IBM. Antonio lives with his dog Santino in Lisbon, Portugal.

All articles