Validating GPT-5 JSON mode with Pydantic is the only reliable way to turn loosely structured model output into typed application data. GPT-5’s response_format={"type":"json_object"} guarantees syntactically valid JSON, but it does not enforce your domain schema—so a validator at the boundary is non-negotiable.
Prerequisites
- Python 3.11 or newer
openai>=1.50.0(OpenAI Python SDK v1)pydantic>=2.7email-validatorif you useEmailStr- An API key for a provider that exposes
gpt-5through an OpenAI-compatible chat completions endpoint
Set your key in the environment:
export OPENAI_API_KEY="sk-..."
Why JSON mode alone is not enough
JSON mode tells the model “emit valid JSON”. It does not say “emit this JSON schema”. In practice GPT-5 will often match your prompt’s described shape, but it may:
- Omit optional fields
- Add extra fields you didn’t ask for
- Use a string for a number (
"age": "29") - Return
nullwhere you expected a value
A Pydantic model is your contract. It parses, coerces, and rejects.
Step 1: Define the target schema
Write the schema you actually want in your codebase, not the schema you hope the model guesses.
from pydantic import BaseModel, Field, EmailStr
class UserProfile(BaseModel):
name: str = Field(min_length=1)
age: int = Field(ge=0, le=120)
email: EmailStr
interests: list[str] = Field(default_factory=list)
model_config = {"extra": "forbid"} # reject unexpected keys
Setting extra="forbid" makes drift visible immediately. If you prefer forward-compatibility, use "ignore" and log extras separately.
Step 2: Call GPT-5 with JSON mode
Use the standard OpenAI client. The response_format parameter is the only model-specific knob we need.
from openai import OpenAI
client = OpenAI() # reads OPENAI_API_KEY
resp = client.chat.completions.create(
model="gpt-5",
messages=[
{
"role": "system",
"content": "You extract user profiles. Respond ONLY with a JSON object "
"matching: name (str), age (int), email (str), interests (list of str).",
},
{
"role": "user",
"content": "Jane Doe, 29, jane@example.com, likes hiking and machine learning.",
},
],
response_format={"type": "json_object"},
temperature=0.0,
)
raw = resp.choices[0].message.content
print(raw)
Expected raw output (formatted for readability):
{
"name": "Jane Doe",
"age": 29,
"email": "jane@example.com",
"interests": ["hiking", "machine learning"]
}
Step 3: Validate the raw string with Pydantic
Do not json.loads then model_validate. Use model_validate_json to avoid double parsing and get better error locations.
from pydantic import ValidationError
try:
profile = UserProfile.model_validate_json(raw)
print("Validated:", profile)
except ValidationError as e:
print("Validation failed:")
print(e)
Successful checkpoint output:
Validated: name='Jane Doe' age=29 email='jane@example.com' interests=['hiking', 'machine learning']
If the model returned {"name":"Jane","age":"29"} (note age as string, missing email), Pydantic coerces the age to int but raises on missing email:
Validation failed:
1 validation error for UserProfile
email
Field required [type=missing, input_value={'name':'Jane','age':29}, input_type=dict]
Step 4: Tighten the loop with retry-on-error
Model output is not a trusted source. Wrap the call so validation errors are fed back as a correction prompt. This turns occasional validation failures into rare ones across a few attempts.
import json
def extract_profile(client: OpenAI, text: str, max_retries: int = 2) -> UserProfile:
sys_msg = (
"You extract user profiles. Respond ONLY with a JSON object "
"matching: name (str), age (int), email (str), interests (list of str)."
)
last_error = None
for attempt in range(max_retries + 1):
messages = [{"role": "system", "content": sys_msg}]
if last_error:
messages.append({
"role": "user",
"content": f"Previous attempt failed validation: {last_error}\n"
f"Fix it. Original text: {text}",
})
else:
messages.append({"role": "user", "content": text})
resp = client.chat.completions.create(
model="gpt-5",
messages=messages,
response_format={"type": "json_object"},
temperature=0.0,
)
raw = resp.choices[0].message.content
try:
return UserProfile.model_validate_json(raw)
except ValidationError as e:
last_error = e.json()
raise RuntimeError("Failed to extract valid profile after retries")
Call it:
profile = extract_profile(client, "Bob, 40, bob@acme.io, enjoys sailing")
print(profile.model_dump())
Expected:
{'name': 'Bob', 'age': 40, 'email': 'bob@acme.io', 'interests': ['sailing']}
Step 5: Dealing with provider instability
When you depend on a single model endpoint, rate limits and 529s are inevitable. If you point the same OpenAI client at n4n.ai’s OpenAI-compatible endpoint, the request shape stays identical and you get automatic fallback when a provider is degraded—no change to the validation code above. The per-token metering and forwarded cache-control hints are handled at the gateway layer.
That said, your Pydantic boundary doesn’t care which provider answered. It only cares about the bytes.
Advanced: Streaming and partial validation
If you stream JSON mode output, do not validate mid-stream. Buffer the full content, then validate once. Pydantic does not support partial-model validation out of the box; use model_construct only when you explicitly trust the source.
For large batches, run validation in a worker pool:
from concurrent.futures import ThreadPoolExecutor
def validate_many(raw_list: list[str]) -> list[UserProfile]:
with ThreadPoolExecutor() as ex:
return list(ex.map(lambda r: UserProfile.model_validate_json(r), raw_list))
Checklist before shipping
- Pydantic model uses
extra="forbid"(or explicit logging of extras) - All numeric/date fields have bounds (
ge,le,constraints) - Validation errors are surfaced to logs with the raw model output
- Retry loop caps attempts and preserves the original user text
- You treat the model output as untrusted input—always
Validating GPT-5 JSON mode with Pydantic is not boilerplate. It is the seam where probabilistic text generation meets deterministic software. Get it right and the rest of your pipeline can assume truth.