Free lesson · GenAI Agent Engineering
Generate JSON Schema from Pydantic models
You can generate JSON Schema with model_json_schema(), control nested-model schema output, attach json_schema_extra and examples, parse Google-style docstrings into descriptions, and reason about Pydantic ↔ JSON Schema draft version compatibility.
Course: GenAI Agent Engineering · Chapter 20 · The Pydantic Tool
Free to read — no subscription required.
Introduction
When wiring LLMs to external tools, you typically need a JSON Schema describing each function's parameters. Writing those schemas by hand means duplicating type information that already exists in your Python code — any field rename or constraint change must be updated in two places, and a missed sync silently breaks tool calls at runtime. In this lesson you will learn to generate valid JSON schemas automatically from Pydantic models using model_json_schema(), and adapt the output into the function-calling formats expected by OpenAI, Anthropic, and Gemini, so your schema stays in sync with your code automatically.
Key Terminology
model_json_schema()— A PydanticBaseModelmethod that converts a Python class definition into a JSON Schema dictionary in one call, capturing field types, constraints, descriptions, and required/optional status automatically.- JSON Schema — A standardized format for describing the structure and constraints of JSON data; the common currency that LLM function-calling APIs (OpenAI, Anthropic, Gemini) use to understand what parameters a tool accepts.
Fieldconstraints — PydanticFieldarguments such asmin_length,ge(greater-than-or-equal), andle(less-than-or-equal) that encode validation rules directly on a model attribute and are reflected in the generated schema asminLength,minimum, andmaximum.- Provider envelope — The provider-specific wrapper structure that surrounds a JSON Schema when registering a tool; OpenAI uses a
"function"/"parameters"envelope, Anthropic uses"input_schema", and Gemini flattens to"properties"and"required"only. - Schema drift — The silent bug that occurs when a hand-written JSON Schema falls out of sync with the Python types it describes; generating the schema from Pydantic models eliminates this class of error by making the code the single source of truth.
Concepts
Why Generate Schemas Instead of Writing Them
Every LLM function-calling API requires a JSON Schema that describes each tool's parameters — their types, which are required, and what constraints they respect. The naive approach is to write this schema by hand alongside your Python code. The problem is that you now have two representations of the same truth: a Python class and a JSON object that must stay in lockstep. Rename a field, tighten a constraint, or add an optional parameter, and you must remember to update both. A missed sync doesn't raise an exception — the LLM simply receives a stale or incorrect description of your tool, and it calls it wrong at runtime.
Generating the schema from your Pydantic model collapses two representations into one. The model is the schema. Every constraint you declare with Field — a minimum string length, a numeric range, a default value — is reflected automatically in the generated output. This means the schema that reaches the LLM is always derived from the same code that validates the LLM's response when it arrives, giving you a closed loop with no gap to drift across.
What model_json_schema() Produces
Calling model_json_schema() on a BaseModel subclass returns a Python dictionary following the JSON Schema specification. The output has a predictable structure: a top-level "title" derived from the class name, a "description" pulled from the docstring, a "properties" block where each field appears with its type and any constraints, and a "required" list containing only fields that have no default value.
The mapping from Pydantic to JSON Schema is mechanical. A Field with min_length=1 becomes "minLength": 1 in the property entry. A Field with ge=1, le=50 becomes "minimum": 1, "maximum": 50. An optional field — one with default=5 or default_factory=dict — is simply absent from "required". This structure is exactly what LLM APIs expect, and you can verify it by inspecting the output directly before wiring it into any provider (see Code Walkthrough).
Adapting One Schema to Multiple Providers
The JSON Schema content is the same regardless of which LLM you target, but each provider wraps it in a different envelope. OpenAI expects the schema nested under "function" → "parameters" and does not want a top-level "title" key. Anthropic places the schema under "input_schema". Gemini accepts only "properties" and "required" at the parameter level, ignoring top-level schema metadata.
The right architectural response is a thin conversion layer: one function per provider, each accepting a BaseModel subclass, calling model_json_schema() internally, and reshaping the output to match that provider's envelope. This keeps the Pydantic model itself provider-agnostic. You can add a fourth provider later by writing one new converter — no changes to the model, no risk of introducing schema drift. The functions pydantic_to_openai_function, pydantic_to_anthropic_tool, and pydantic_to_gemini_declaration in the Code Walkthrough demonstrate this pattern with the same SearchQuery model feeding all three.
Code Walkthrough
Now that you've seen why to generate schemas instead of writing them, what model_json_schema() produces, and how to adapt one schema to multiple providers, this walkthrough turns them into working code.
Pydantic's model_json_schema() method converts any BaseModel subclass into a JSON Schema dictionary in one call. The schema captures field types, Field constraints (min_length, ge, le), descriptions, and which fields are required versus optional — precisely the structure LLM function-calling APIs need.
Code snippetpython
1from pydantic import BaseModel, Field 2import json 3 4class SearchQuery(BaseModel): 5 """Search for documents in the knowledge base.""" 6 7 query: str = Field(..., description="The search query text", min_length=1) 8 filters: dict = Field(default_factory=dict, description="Optional filters as key-value pairs") 9 top_k: int = Field(default=5, ge=1, le=50, description="Number of results to return") 10 11schema = SearchQuery.model_json_schema() 12print(json.dumps(schema, indent=2)) 13# Output includes: 14# "description": "Search for documents in the knowledge base." 15# "properties": { "query": {..., "minLength": 1}, "top_k": {..., "minimum": 1, "maximum": 50} } 16# "required": ["query"]
The output schema includes "title" (from the class name), "description" (from the docstring), a "properties" block with each field's type and constraints, and a "required" list containing only fields that have no default. This is ready to pass directly to any LLM that accepts JSON Schema.
Different providers wrap the same schema differently. OpenAI embeds it in a "function" / "parameters" envelope; Anthropic puts it under "input_schema"; Gemini flattens it under "parameters" with only properties and required. A single conversion layer handles all three from the same Pydantic class:
Code snippetpython
1from pydantic import BaseModel 2from typing import Type, Dict, Any 3 4def pydantic_to_openai_function(model: Type[BaseModel]) -> Dict[str, Any]: 5 schema = model.model_json_schema() 6 schema.pop("title", None) # OpenAI does not expect a top-level "title" 7 return {"type": "function", "function": { 8 "name": model.__name__, 9 "description": model.__doc__ or "", 10 "parameters": schema 11 }} 12 13def pydantic_to_anthropic_tool(model: Type[BaseModel]) -> Dict[str, Any]: 14 schema = model.model_json_schema() 15 return {"name": model.__name__, "description": model.__doc__ or "", "input_schema": schema} 16 17def pydantic_to_gemini_declaration(model: Type[BaseModel]) -> Dict[str, Any]: 18 schema = model.model_json_schema() 19 return {"name": model.__name__, "description": model.__doc__ or "", "parameters": { 20 "type": "object", 21 "properties": schema.get("properties", {}), 22 "required": schema.get("required", []) 23 }}
Each function accepts the same Pydantic class and reshapes the output for the target provider. The "title" key is dropped for OpenAI because that provider does not expect it at the top level; Anthropic and Gemini tolerate it but the Gemini converter extracts only properties and required to match its simplified parameter structure. Additional customization — such as injecting example values via model_config = ConfigDict(json_schema_extra={"examples": [...]}) — can be layered on any model without modifying the converters.
To confirm everything is wired correctly, run print(SearchQuery.model_json_schema()) and verify the output contains "required": ["query"] and a "top_k" property with both "minimum": 1 and "maximum": 50; if those keys are present, model_json_schema() is working as expected and all three converter functions will produce valid tool definitions.
Do's and Don'ts
Having walked through JSON schema generation above, the following Do's and Don'ts distill it into practice.
Do's
- ✓Do define field constraints with
Fieldparameters likemin_length,ge, andle—model_json_schema()translates these directly into JSON Schema keywords ("minLength","minimum","maximum"), so the LLM provider enforces your validation rules without any separate schema authoring. - ✓Do write the docstring on every
BaseModelsubclass used as a tool — all three converter functions (pydantic_to_openai_function,pydantic_to_anthropic_tool,pydantic_to_gemini_declaration) pull the tool description frommodel.__doc__; a missing docstring silently sends an empty string as the description, leaving the LLM with no guidance on when to call the tool. - ✓Do strip the top-level
"title"key before passing the schema to OpenAI —model_json_schema()always emits"title"from the class name, but OpenAI's function-calling format does not expect it at the parameters level; thepydantic_to_openai_functionconverter callsschema.pop("title", None)for exactly this reason.
Don'ts
- ✗Don't hand-write JSON Schema alongside your Pydantic models — duplicating type information in two places means any field rename or constraint change (e.g., tightening
top_k'sle=50) must be updated in both the model and the schema; a missed sync silently breaks tool calls at runtime with no Python-level error. - ✗Don't pass the raw
model_json_schema()output to every provider interchangeably — Anthropic expects the schema under"input_schema", Gemini expects onlypropertiesandrequiredflattened under"parameters", and OpenAI uses a"function"/"parameters"envelope; skipping the provider-specific converter produces a malformed tool definition the API will reject or misinterpret. - ✗Don't omit a default value for optional fields —
model_json_schema()builds the"required"list from fields that have no default; a field liketop_kthat should be optional but is declared withoutdefault=5ordefault_factorywill appear in"required", forcing callers to supply it even when a sensible default exists.
This lesson is free to read. Its hands-on lab — real code, in a cloud IDE — is part of the GenAI Agent Engineering subscription.
From · cancel anytime
More free lessons in GenAI Agent Engineering
- Ch 16Build with LangGraph StateGraph
- Ch 20Generate JSON Schema from Pydantic modelsYou are here
- Ch 20Build a Pydantic tool library
- Ch 24Create an MCP server with lifecycle management
- Ch 24Define MCP tools
- Ch 24Implement MCP resources
- Ch 25Manage MCP server lifecycle