Getting reliable structured data from a language model means validating nested Pydantic schemas for LLM output before it touches your business logic. A flat model rarely survives contact with real prompts; you need recursive validation, strict typing, and a rejection path for malformed generations. This guide walks through a concrete pipeline you can ship today.
Step 1: Define explicit nested domain models
Start by modeling the response shape as a tree of Pydantic BaseModel classes. The LLM will emit JSON that mirrors this tree, and Pydantic will walk it recursively.
from pydantic import BaseModel, EmailStr, Field
from typing import List, Optional
class Address(BaseModel):
street: str
city: str
zip_code: str = Field(pattern=r"^\d{5}$")
class Customer(BaseModel):
name: str
email: EmailStr
address: Optional[Address] = None
class LineItem(BaseModel):
sku: str
quantity: int = Field(ge=1)
unit_price_cents: int = Field(ge=0)
class SupportTicket(BaseModel):
ticket_id: str
customer: Customer
items: List[LineItem]
notes: Optional[str] = None
Nested models give you layered validation for free. The inner Address enforces a five-digit ZIP; LineItem rejects non-positive quantities. When validating nested Pydantic schemas for LLM output, the outer SupportTicket call triggers a depth-first check of every child. If the model returns items: [{"sku": "x", "quantity": 0}], the error points at items.0.quantity, not a generic parse failure.
Install the email validator before running: pip install pydantic email-validator.
Step 2: Enable strict mode and forbid extra fields
Pydantic v2’s default mode coerces types—"3" becomes 3. For LLM output that leniency hides prompt regressions. Turn on strict mode and forbid unknown keys.
from pydantic import ConfigDict
class SupportTicket(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
ticket_id: str
customer: Customer
items: List[LineItem]
notes: Optional[str] = None
class Customer(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
name: str
email: EmailStr
address: Optional[Address] = None
Strict mode rejects "quantity": "3" because it is a string, not an int. extra="forbid" catches schema drift when a model decides to add confidence_score at the top level. Apply the config on every nested class; children do not inherit the parent’s model_config.
Add field-level rules where domain logic demands:
from pydantic import field_validator
class LineItem(BaseModel):
model_config = ConfigDict(strict=True)
sku: str
quantity: int = Field(ge=1)
unit_price_cents: int = Field(ge=0)
@field_validator("sku")
@classmethod
def sku_must_be_upper(cls, v):
if not v.isupper():
raise ValueError("SKU must be uppercase")
return v
Step 3: Fetch raw completion and parse without coercion
Call the model through an OpenAI-compatible client. A gateway like n4n.ai exposes one OpenAI-compatible endpoint that addresses 240+ models and handles provider fallback, so the parsing code below stays identical regardless of which backend generated the text.
from openai import OpenAI
client = OpenAI(base_url="https://api.n4n.ai/v1", api_key="YOUR_KEY")
resp = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
)
raw = resp.choices[0].message.content
Parse with model_validate_json, not the deprecated parse_raw:
from pydantic import ValidationError
try:
ticket = SupportTicket.model_validate_json(raw)
except ValidationError as e:
# capture e.errors() for logging or retry
raise
If you are validating nested Pydantic schemas for LLM output from a smaller model, expect frequent ValidationErrors. Strict config surfaces them immediately instead of letting a coerced None slip into your database.
Step 4: Extract actionable validation errors
A raw ValidationError is not operator-friendly. Flatten it to dotted paths:
def format_errors(e: ValidationError) -> str:
lines = []
for err in e.errors():
loc = ".".join(str(x) for x in err["loc"])
lines.append(f"{loc}: {err['msg']} (got {err['input']!r})")
return "\n".join(lines)
Use this to alert or to feed a correction loop. The location customer.address.zip_code tells you the model emitted a bad ZIP inside an optional block. You can drop the address and retry, or reject the whole ticket. Either way, the nested path removes guesswork.
Step 5: Use discriminated unions for polymorphic nested data
Real extracts often contain varying subtypes. A discriminated union forces the model to tag the variant.
from typing import Union, Annotated
from pydantic import Field
class PaymentCard(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
type: str = Field(literal="card")
last4: str
class BankTransfer(BaseModel):
model_config = ConfigDict(strict=True, extra="forbid")
type: str = Field(literal="bank")
iban: str
PaymentMethod = Annotated[Union[PaymentCard, BankTransfer], Field(discriminator="type")]
class Order(BaseModel):
id: str
payment: PaymentMethod
Now validating nested Pydantic schemas for LLM output that includes payment handles both shapes and rejects an unknown type value. If the model omits the discriminator, Pydantic raises a clear error instead of picking a random branch.
For LLMs that forget the tag, add a model_validator on the parent to infer it:
from pydantic import model_validator
class Order(BaseModel):
id: str
payment: Union[PaymentCard, BankTransfer]
@model_validator(mode="before")
@classmethod
def infer_payment_type(cls, data):
p = data.get("payment", {})
if "last4" in p and "type" not in p:
p["type"] = "card"
elif "iban" in p and "type" not in p:
p["type"] = "bank"
return data
Use this only as a fallback; explicit discriminators are safer.
Step 6: Build a verification harness
Write pytest cases that lock the schema behavior. Feed known-good and known-bad JSON.
import pytest
from pydantic import ValidationError
def test_valid_ticket():
data = '''{
"ticket_id": "T-1",
"customer": {"name": "Jane", "email": "jane@x.com"},
"items": [{"sku": "ABC", "quantity": 2, "unit_price_cents": 500}]
}'''
t = SupportTicket.model_validate_json(data)
assert t.customer.name == "Jane"
assert t.items[0].quantity == 2
def test_invalid_ticket():
bad = '''{
"ticket_id": "T-2",
"customer": {"name": "Jane", "email": "not-an-email"},
"items": []
}'''
with pytest.raises(ValidationError):
SupportTicket.model_validate_json(bad)
def test_strict_rejects_string_quantity():
bad = '''{
"ticket_id": "T-3",
"customer": {"name": "Jane", "email": "jane@x.com"},
"items": [{"sku": "ABC", "quantity": "2", "unit_price_cents": 500}]
}'''
with pytest.raises(ValidationError):
SupportTicket.model_validate_json(bad)
Run pytest -q. Green means your nested constraints hold. For end-to-end verification, script a curl to your inference endpoint, pipe the body into a ten-line loader that calls model_validate_json, and check the exit code. If the process exits non-zero on a sample prompt, your validation gate works.
Add a fixture test for the discriminated union:
def test_payment_discriminator():
good = '{"id": "O1", "payment": {"type": "card", "last4": "1234"}}'
o = Order.model_validate_json(good)
assert o.payment.last4 == "1234"
bad = '{"id": "O2", "payment": {"type": "crypto", "addr": "x"}}'
with pytest.raises(ValidationError):
Order.model_validate_json(bad)
Validating nested Pydantic schemas for LLM output is not a one-time write. As your prompts evolve, these tests are the canary. Keep the strict config on, keep the unions tagged, and treat every ValidationError as a signal about model behavior rather than a nuisance to silence.