n4nAI

Using Pydantic to define OpenAI function calling schemas

Define OpenAI tool schemas from Python types using Pydantic to avoid JSON Schema drift, with runnable steps for generation, calling, and validation.

n4n Team3 min read725 words

Audio narration

Coming soon — every post will get a voice note here.

Hand-writing JSON Schema for OpenAI tool calls is error-prone and silently drifts from the Python code that actually executes the tool. Using pydantic openai function calling schemas lets you define a single source of truth: a Pydantic model becomes both your validator and your wire format. This post walks through a concrete, end-to-end pattern you can drop into a production service.

Step 1: Install dependencies and import the basics

You need Pydantic v2 and an OpenAI-compatible client. The openai package works against any endpoint that speaks the v1 chat completions protocol.

pip install "pydantic>=2.0" openai

Import what we need for the next steps:

from pydantic import BaseModel, Field, ValidationError
from openai import OpenAI
import json
from enum import Enum

That’s it. No schema DSL, no separate YAML files to keep in sync.

Step 2: Define the tool input as a Pydantic model

A tool call is just a typed function invocation. Model the arguments exactly as you would model an API request. Use Field to attach descriptions—OpenAI passes these to the model, so they directly affect tool-selection accuracy and argument quality.

class Unit(str, Enum):
    CELSIUS = "celsius"
    FAHRENHEIT = "fahrenheit"

class GetWeatherArgs(BaseModel):
    latitude: float = Field(..., ge=-90, le=90, description="Decimal latitude of the target")
    longitude: float = Field(..., ge=-180, le=180, description="Decimal longitude of the target")
    unit: Unit = Field(default=Unit.CELSIUS, description="Temperature unit")

This single class is the core of your pydantic openai function calling schemas. The ... marks required fields; default makes unit optional on the wire. Pydantic serializes the enum to its string value in JSON Schema, which is exactly what the model expects. Numeric constraints (ge, le) propagate to minimum/maximum in the schema and tighten your runtime validation.

Step 3: Convert the model to an OpenAI tool schema

Pydantic v2 emits JSON Schema draft 2020-12 via model_json_schema(). OpenAI’s tools API accepts a subset and ignores unknown keys, but $defs should be renamed to definitions if you have nested models. Strip title to keep the payload small.

def to_openai_tool(model: type[BaseModel], name: str, description: str) -> dict:
    schema = model.model_json_schema()
    if "$defs" in schema:
        schema["definitions"] = schema.pop("$defs")
    schema.pop("title", None)
    return {
        "type": "function",
        "function": {
            "name": name,
            "description": description,
            "parameters": schema,
        },
    }

For the weather example:

tool = to_openai_tool(GetWeatherArgs, "get_weather", "Fetch current weather for a coordinate")

The generated parameters object includes properties, required, and additionalProperties: false (Pydantic sets this by default). That is a valid pydantic openai function calling schemas payload.

If your model references another model, the nested definition lands in definitions and $ref points to it. OpenAI handles $ref to definitions fine. Do not use recursive models—the schema will blow up and the API will reject it. Also avoid nullable unions; the model handles anyOf poorly. Use explicit optionals with defaults instead.

Step 4: Send the tool to the chat completions API

Instantiate the client. If you point it at n4n.ai’s OpenAI-compatible endpoint, the same tool schema works across 240+ models and you get automatic fallback when a provider is rate-limited. For standard OpenAI, just use the default base URL.

client = OpenAI()  # or OpenAI(base_url="https://api.n4n.ai/v1", api_key="...")
tools = [tool]

resp = client.chat.completions.create(
    model="gpt-4o-mini",
    messages=[{"role": "user", "content": "What's the temperature at 37.77, -122.41?"}],
    tools=tools,
    tool_choice="auto",
)

The model may return a tool_calls array on the assistant message. If it does, it has committed to invoking your function with JSON arguments. You can force the call with tool_choice={"type": "function", "function": {"name": "get_weather"}} when you know the tool is required.

Step 5: Validate the model’s arguments with Pydantic

Never execute a tool call by blindly json.loads-ing the arguments. Pass them through the model constructor to get type coercion and validation.

msg = resp.choices[0].message
if msg.tool_calls:
    raw = msg.tool_calls[0].function.arguments
    try:
        data = json.loads(raw)
        args = GetWeatherArgs(**data)  # raises ValidationError on bad input
    except (json.JSONDecodeError, ValidationError) as e:
        # return a tool error message to the model, or fall back
        raise RuntimeError(f"Invalid tool args: {e}")

    # now call your actual weather API with args.latitude, args.longitude, args.unit
    print(f"Fetching {args.unit.value} weather at {args.latitude},{args.longitude}")

If the model omitted unit, Pydantic fills the default. If it returned "unit": "kelvin", the enum raises ValidationError—you catch it and return a corrective prompt. This closed loop is why pydantic openai function calling schemas earn their keep: the same types that generate the schema also guard your runtime.

Step 6: Verify the round-trip

Success means: (1) the schema sent to the API is valid JSON Schema, (2) the model returns a tool call, (3) Pydantic accepts the arguments. A quick assertion-based check in a script:

assert "parameters" in tool["function"]
assert tool["function"]["name"] == "get_weather"
assert msg.tool_calls is not None
assert abs(args.latitude - 37.77) < 0.01

Run the script. If it prints the fetching line and no exception, your pipeline works. For CI, serialize tool and diff against a golden file to catch accidental schema changes from dependency upgrades.

Handling nested structures and arrays

Real tools rarely take two floats. Pydantic handles list and nested BaseModel cleanly:

class Location(BaseModel):
    lat: float = Field(..., ge=-90, le=90)
    lon: float = Field(..., ge=-180, le=180)

class MultiWeatherArgs(BaseModel):
    locations: list[Location] = Field(..., description="Up to 10 coordinates")
    unit: Unit = Field(default=Unit.CELSIUS)

to_openai_tool moves Location into definitions and emits {"$ref": "#/definitions/Location"}. Keep nesting shallow; the model’s ability to emit correct deep JSON degrades past two levels.

Production notes

  • Set model_config = {"extra": "forbid"} on your args models. This makes additionalProperties: false explicit and blocks the model from sneaking in unknown keys.
  • If you need examples in the prompt, put them in the description, not json_schema_extra—OpenAI ignores examples in parameters.
  • Cache the generated tool dict at import time. model_json_schema() is cheap but pointless to call per request.
  • When streaming, accumulate tool_calls[].function.arguments across delta chunks before parsing.
  • Version your tool names (get_weather_v2) when you change required fields, because older prompts or cached conversations may assume the old shape.

That’s the full loop: define once, generate, call, validate. Your pydantic openai function calling schemas stay in lockstep with your code, and the model gets accurate, typed contracts instead of hand-maintained JSON you forgot to update last sprint.

Tagspythonpydanticfunction-callingjson-schema

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All function calling in python posts →