Free lesson · GenAI Agent Engineering
Use Pydantic for tool schemas
You can derive JSON Schema from Pydantic models with model_json_schema(), use model_validator for cross-field rules, attach metadata via Annotated, parse Google-style docstrings into descriptions, and keep schema versions disciplined.
Course: GenAI Agent Engineering · Chapter 15 · The Tool Definer
Free to read — no subscription required.
Introduction
When you hand-write JSON Schema for every LLM tool, the schemas drift out of sync with the Python code that consumes the model's arguments — and the first sign of trouble is a tool call that validates upstream but explodes inside your handler. Teams that adopt Pydantic as the single source of truth for tool parameters get type-checked Python, runtime validation, and provider-ready JSON Schema from one model definition. By the end of this lesson you'll be able to define a Pydantic model for a tool's parameters, generate an OpenAI- or Anthropic-compatible schema from it, and explain why a $ref in the output may need to be inlined before the provider will accept it.
Key Terminology
- Pydantic BaseModel — the class you subclass to declare a tool's parameter shape; gives you both Python-side validation and a
model_json_schema()method that emits JSON Schema for the LLM. - Field() — Pydantic's per-attribute configurator; sets descriptions, constraints (
min_length,ge), defaults, andjson_schema_extraso the LLM sees rich hints alongside the type. - model_json_schema() — the BaseModel method that converts the model into a JSON Schema dict. Output usually needs light post-processing (drop
title, optionally inline$defs) before sending to a provider. $defs/$ref— JSON Schema's mechanism for nested model definitions. OpenAI's function-calling format accepts$ref; older or stricter providers may need refs inlined.- Tool schema envelope — the provider-specific wrapper around the parameter schema. OpenAI uses
{"type": "function", "function": {..., "parameters": <schema>}}; Anthropic uses{"name": ..., "input_schema": <schema>}.
Concepts
Why Pydantic owns the tool contract
A tool definition has two consumers: the LLM (which reads JSON Schema to decide what arguments to produce) and your Python handler (which receives those arguments and must validate them). Maintaining two hand-written copies — a JSON Schema dict and a separate parsing routine — guarantees they will diverge. A Pydantic model is the single source: the class itself enforces types at handler entry, and model_json_schema() derives the schema the LLM sees.
From model to provider payload
model_json_schema() produces standards-compliant JSON Schema, but providers expect specific envelopes. OpenAI wraps the schema under function.parameters; Anthropic puts it under input_schema. A thin generator function per provider does the wrapping. It also strips Pydantic-specific keys (notably title) that providers either ignore or reject, and optionally inlines $ref pointers when the target provider can't resolve them (see Code Walkthrough).
Field-level hints sharpen LLM behaviour
Field(description=..., examples=..., ge=...) and json_schema_extra={"enum": [...]} flow into the generated schema. The LLM reads these to decide what values are valid; the Python runtime uses the same constraints to validate the argument when the tool fires. One declaration, two enforcement points.
Code Walkthrough
Building on the model-to-schema pipeline from the Concepts section, the code below shows a single ProductSearch model serving both Python-side validation and two provider-specific schema generators.
Code snippetpython
1from typing import Dict, Optional, Type 2from decimal import Decimal 3from pydantic import BaseModel, Field, ConfigDict 4 5class ProductSearch(BaseModel): 6 """Parameters for a product search tool call.""" 7 8 model_config = ConfigDict( 9 json_schema_extra={ 10 "examples": [{"query": "wireless headphones", "category": "electronics", "price_max": 200.00}] 11 } 12 ) 13 14 query: str = Field( 15 ..., description="Product search query", min_length=2, max_length=200, 16 json_schema_extra={"examples": ["laptop", "running shoes"]}, 17 ) 18 category: Optional[str] = Field( 19 default=None, description="Product category filter", 20 json_schema_extra={"enum": ["electronics", "clothing", "home", "sports", "books"]}, 21 ) 22 price_max: Optional[Decimal] = Field(default=None, description="Max price in USD", ge=0) 23 in_stock: bool = Field(default=True, description="Only show items currently in stock") 24 25def inline_refs(schema: Dict, defs: Dict) -> Dict: 26 """Recursively replace {'$ref': '#/$defs/X'} with the resolved definition.""" 27 if isinstance(schema, dict): 28 if "$ref" in schema: 29 ref_name = schema["$ref"].split("/")[-1] 30 return inline_refs(defs.get(ref_name, {}), defs) 31 return {k: inline_refs(v, defs) for k, v in schema.items()} 32 if isinstance(schema, list): 33 return [inline_refs(item, defs) for item in schema] 34 return schema 35 36def generate_openai_schema(model: Type[BaseModel], name: str, description: str) -> Dict: 37 schema = model.model_json_schema() 38 schema.pop("title", None) 39 if "$defs" in schema: 40 defs = schema.pop("$defs") 41 schema = inline_refs(schema, defs) 42 return {"type": "function", "function": {"name": name, "description": description, "parameters": schema}} 43 44def generate_anthropic_schema(model: Type[BaseModel], name: str, description: str) -> Dict: 45 schema = model.model_json_schema() 46 schema.pop("title", None) 47 return {"name": name, "description": description, "input_schema": schema}
Both generators read from the same ProductSearch definition — the only difference is the envelope shape each provider expects. generate_openai_schema strips $defs and inlines any $ref nodes via inline_refs; generate_anthropic_schema leaves the schema as-is because Anthropic accepts $defs. Neither function modifies ProductSearch itself: the model stays the canonical source of truth and each generator derives from it independently. The Field() constraints (min_length=2, ge=0) and json_schema_extra hints ("enum", "examples") flow into the generated schema automatically — one declaration that both the LLM and the Python runtime enforce.
Confirm that everything is wired correctly by running two checks in a Python shell: generate_openai_schema(ProductSearch, "search_products", "Search the catalog")["function"]["parameters"]["properties"]["query"] should contain "minLength": 2 and no "title" key, and ProductSearch(**{"query": "x"}) should raise a pydantic.ValidationError, proving that the same min_length=2 constraint the LLM sees is also enforced at runtime.
Do's and Don'ts
Having walked through using Pydantic for tool schemas above, the following Do's and Don'ts distill it into practice.
Do's
- ✓Do treat the Pydantic model as the contract — generate the schema, don't hand-edit it.
- ✓Do strip
titleand inline$defsper provider — most LLM providers ignore or reject Pydantic's defaulttitlekey, and some need refs resolved. - ✓Do attach
descriptionandexampleson every Field — the LLM uses them to pick argument values; missing descriptions produce vague tool calls.
Don'ts
- ✗Don't maintain a hand-written JSON Schema alongside the Pydantic model — they will drift and produce silent validation/handler mismatches.
- ✗Don't skip
min_length/ge/enum constraints — without them, the LLM can produce empty strings or out-of-range numbers that pass schema validation but break the handler. - ✗Don't reuse one envelope across providers — OpenAI's
function.parametersand Anthropic'sinput_schemaare not interchangeable.
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