n4nAI

Validating GPT-5 JSON mode output with Pydantic

Learn how to validate GPT-5 JSON mode output with Pydantic in a hands-on tutorial, turning loose model JSON into typed, schema-checked data.

n4n Team2 min read530 words

Audio narration

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

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.7
  • email-validator if you use EmailStr
  • An API key for a provider that exposes gpt-5 through 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 null where 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.

Tagsjson-modepydanticgpt-5structured-output

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 structured output validation posts →