n4nAI

Validating nested structured output schemas with Pydantic

Learn how to validate nested Pydantic schemas for LLM output with practical steps, code samples, and verification tips for robust structured generation.

n4n Team3 min read570 words

Audio narration

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

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.

Tagspydanticstructured-outputschema-validationnested-schemas

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 →