Pydantic Models With the SDK's Parse Helpers
Why Hand-Written JSON Schemas Get Tedious
Lesson 2 built structured-output schemas as plain Python dictionaries — precise, fully functional, but verbose, especially once nested objects, arrays of objects, and several related fields are involved. Writing and maintaining these dictionaries by hand also means the schema and the Python code that eventually consumes the resulting data are two separate things that have to be kept manually in sync: nothing stops the schema from saying "order_id" while the code that reads the parsed result accesses data["orderId"], and nothing catches that mismatch until it fails at runtime.
# Lesson 2's approach: schema and consuming code are two separate, manually-synced things
schema = {
"type": "object",
"properties": {"order_id": {"type": "string"}, "total": {"type": "number"}},
"required": ["order_id", "total"],
"additionalProperties": False,
}
# ... elsewhere in the codebase ...
data = json.loads(response.output_text)
print(data["order_id"]) # Works only if this key name matches the schema exactly
Pydantic — a widely used Python library for data validation, built around ordinary-looking class definitions — solves this by letting a single class definition serve as both the schema and the typed Python object your code works with afterward, and the OpenAI SDK provides dedicated helper methods that take a Pydantic model directly, generate the equivalent JSON Schema automatically, and hand back an already-validated, already-typed instance of that model rather than a raw dictionary.
Defining a Pydantic Model
A Pydantic model is a Python class inheriting from BaseModel, with class-level type annotations describing each field — syntax that will look immediately familiar if you've used Python's dataclasses or type hints generally, but with real runtime validation behavior attached.
from pydantic import BaseModel
class PersonExtraction(BaseModel):
name: str
age: int
city: str
This one class definition is a complete, equivalent replacement for Lesson 2's hand-written dictionary schema — name: str, age: int, and city: str correspond directly to "name": {"type": "string"}, "age": {"type": "integer"}, and "city": {"type": "string"}, with all three fields required by default (a field becomes optional in Pydantic by giving it a default value or wrapping its type in Optional, covered later in this lesson). Beyond generating a schema, this class also becomes a real, usable Python object once populated — with attribute access (person.name), type-checked construction, and all the other behavior a normal Python class would have.
Using client.responses.parse()
The SDK exposes a dedicated method — parse(), alongside the create() method used throughout this course — specifically for working with Pydantic models. It accepts the model class directly as a text_format argument (or, in some SDK versions, a similarly named parameter — confirm the exact argument name against your installed version), builds the equivalent JSON Schema automatically, and returns a response whose parsed output is already a validated instance of your class.
response = client.responses.parse(
model="gpt-5.6-luna",
input="Extract the name, age, and city from: 'Maria Gomez, 34, lives in Austin.'",
text_format=PersonExtraction,
)
person = response.output_parsed
print(person.name) # "Maria Gomez"
print(person.age) # 34, already an int — no json.loads() or manual conversion needed
print(person.city) # "Austin"
print(type(person)) # <class '__main__.PersonExtraction'>
Note: The exact parameter name (
text_formathere) and the exact attribute holding the parsed result (output_parsedhere) are specific to the SDK version this course targets — always confirm the current names against your installed SDK version's documentation, since a helper method's naming conventions are more likely to shift across versions than the underlying JSON Schema mechanics Lesson 2 covered.
Notice the meaningful difference from Lesson 2's approach: there's no json.loads() call anywhere in this code, and person.age is already a native Python int, ready for arithmetic, rather than a value pulled out of a dictionary that still needed type conversion or validation. The parse() helper handles the entire round trip — building the schema from the class, sending the request, parsing the JSON response, and validating and constructing an instance of your class — collapsing several manual steps from Lesson 2's approach into one method call.
Nested Models
Pydantic models can nest inside one another, exactly mirroring the nested-object schemas Lesson 2 built by hand, but expressed as ordinary Python class composition rather than nested dictionary literals.
from pydantic import BaseModel
class LineItem(BaseModel):
name: str
quantity: int
class ShippingAddress(BaseModel):
street: str
city: str
zip_code: str
class Order(BaseModel):
order_id: str
items: list[LineItem]
shipping_address: ShippingAddress
response = client.responses.parse(
model="gpt-5.6-luna",
input="Order #7723: 2 wireless mice and 1 keyboard, ship to 44 Oak Street, Denver, 80202.",
text_format=Order,
)
order = response.output_parsed
print(order.order_id) # "7723"
print(order.items[0].name, order.items[0].quantity) # "wireless mice" 2
print(order.shipping_address.city) # "Denver"
Compare this directly to Lesson 2's equivalent hand-written schema for the same data: the nested dictionary literals describing items as an array of objects and shipping_address as a nested object have been replaced by ordinary, readable Python class definitions (LineItem, ShippingAddress, Order) that most Python developers would recognize instantly, with list[LineItem] doing the work Lesson 2's {"type": "array", "items": {...}} did explicitly. The resulting order object supports normal attribute access at every level of nesting (order.items[0].name, not data["items"][0]["name"]), which is both more readable and gives you IDE autocomplete and static type checking that a raw dictionary never could.
Optional Fields and Defaults
A field that may legitimately be absent is expressed in Pydantic using Optional (or the equivalent | None union syntax in modern Python) combined with a default value, mirroring Lesson 2's nullable-field pattern but expressed through ordinary Python typing.
from typing import Optional
class ReviewExtraction(BaseModel):
customer_name: Optional[str] = None
sentiment: str
response = client.responses.parse(
model="gpt-5.6-luna",
input="Extract the customer name and sentiment from: 'this product is fine i guess'",
text_format=ReviewExtraction,
)
result = response.output_parsed
print(result.customer_name) # None — the model wasn't given the option to omit the field
print(result.sentiment) # "neutral"
Under the hood, Optional[str] = None is translated by the parse() helper into the same nullable-type, required-key JSON Schema pattern Lesson 2 built directly ("type": ["string", "null"], still listed in "required") — the Pydantic layer is a more ergonomic way of expressing exactly the same underlying schema-level guarantee, not a different mechanism altogether.
Enums With Pydantic
Python's built-in enum.Enum class serves the same role Lesson 2's "enum" schema constraint did, and the parse() helper translates it into the equivalent constrained schema automatically.
from enum import Enum
class TicketCategory(str, Enum):
BILLING = "billing"
TECHNICAL_SUPPORT = "technical_support"
ACCOUNT_ACCESS = "account_access"
FEATURE_REQUEST = "feature_request"
OTHER = "other"
class Urgency(str, Enum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
class TicketClassification(BaseModel):
category: TicketCategory
urgency: Urgency
response = client.responses.parse(
model="gpt-5.6-luna",
input="Classify this support ticket: 'I can't log into my account and I need access urgently for a client call in an hour.'",
text_format=TicketClassification,
)
result = response.output_parsed
print(result.category) # TicketCategory.ACCOUNT_ACCESS
print(result.category.value) # "account_access"
print(result.urgency) # Urgency.HIGH
Inheriting from both str and Enum (class TicketCategory(str, Enum)) is a deliberate, commonly used pattern here — it makes each enum member simultaneously behave like a string (useful for comparisons, serialization, and logging: result.category == "account_access" works directly) and like a proper enum member (useful for exhaustiveness checking and IDE support). This is a small but genuinely useful Python idiom worth adopting for any enum used in a Pydantic model that will also need to be compared against or serialized as plain strings elsewhere in an application.
Validating Beyond Type: Pydantic's Field and Validators
Pydantic supports constraints beyond basic types — minimum and maximum numeric bounds, string length limits, custom validation logic — through Field() and validator decorators, some of which translate directly into JSON Schema constraints the model's generation is constrained by, and some of which are Pydantic-side validation applied after parsing.
from pydantic import BaseModel, Field
class ProductReview(BaseModel):
product_name: str
rating: int = Field(ge=1, le=5) # ge = greater-or-equal, le = less-or-equal
review_text: str
response = client.responses.parse(
model="gpt-5.6-luna",
input="Review: 'The Aria 2 headphones are fantastic, I'd give them a 5.'",
text_format=ProductReview,
)
result = response.output_parsed
print(result.rating) # Guaranteed to be an integer between 1 and 5 inclusive
Note: Whether a given Pydantic constraint (like
Field(ge=1, le=5)) is translated into an enforced part of the JSON Schema the model is constrained against, versus being validated only after the fact against an already-generated response, is a detail that can vary by constraint type and SDK version. Numeric bounds and enums are commonly supported as enforced schema constraints; more exotic custom validators may only run as post-hoc Python-side validation. Confirm which category a given constraint falls into for your SDK version before relying on it as a hard generation-time guarantee versus a validation check applied afterward.
Handling Validation Errors
Even with a Pydantic model driving generation, it's worth building in explicit handling for the case where parsing or validation fails — whether due to a schema the model couldn't satisfy, a value falling outside a Field() constraint that's validated post-hoc rather than enforced during generation, or an unexpected API-level issue.
from pydantic import ValidationError
def safe_parse_order(prompt: str) -> Order | None:
try:
response = client.responses.parse(
model="gpt-5.6-luna",
input=prompt,
text_format=Order,
)
return response.output_parsed
except ValidationError as e:
print(f"Response didn't validate against the Order model: {e}")
return None
except Exception as e:
print(f"Request failed: {e}")
return None
result = safe_parse_order("Order #7723: 2 wireless mice, ship to 44 Oak Street, Denver, 80202.")
if result:
print(f"Parsed order {result.order_id} successfully")
else:
print("Could not parse a valid order from this input")
Distinguishing a ValidationError (data that came back but didn't satisfy the model's constraints) from a general Exception (a request-level failure — network issue, invalid API key, rate limiting) keeps error handling precise, letting an application respond differently to "the model's output didn't validate" versus "the request itself failed," which are different problems calling for different remediation — Lesson 4 of this unit builds directly on this distinction with a deeper look at handling refusals and validation failures specifically.
Converting a Parsed Model Back to a Dictionary or JSON
Once you have a validated Pydantic instance, converting it back into a plain dictionary or a JSON string — for storage, for logging, for passing to another system that expects raw JSON — is a single built-in method call, rather than anything you need to write yourself.
person = response.output_parsed # a PersonExtraction instance from earlier in this lesson
as_dict = person.model_dump()
print(as_dict) # {'name': 'Maria Gomez', 'age': 34, 'city': 'Austin'}
as_json = person.model_dump_json()
print(as_json) # '{"name":"Maria Gomez","age":34,"city":"Austin"}'
model_dump() and model_dump_json() are Pydantic's own built-in serialization methods, present on every BaseModel subclass regardless of whether it was used with this SDK's parse() helper — worth knowing about since a Pydantic-modeled extraction result frequently needs to be written to a database, logged, or passed to a downstream system that expects a plain dictionary or JSON string rather than a Pydantic object specifically.
Choosing Between Raw JSON Schema and Pydantic
Both approaches — Lesson 2's hand-written JSON Schema dictionaries and this lesson's Pydantic models — ultimately drive the exact same underlying strict-schema mechanism; neither is more "powerful" than the other in terms of what the model can be constrained to produce. The choice between them is almost entirely about developer ergonomics and codebase fit.
| Consideration | Raw JSON Schema (Lesson 2) | Pydantic models (this lesson) |
|---|---|---|
| Verbosity | More verbose — full dictionary literals | More concise — ordinary Python class syntax |
| Type safety in consuming code | None — a dict, prone to key-name typos | Full — a typed object with attribute access |
| Schema/code synchronization | Manual — schema and consuming code can drift apart | Automatic — one class definition drives both |
| Language-agnostic portability | Yes — the schema is plain JSON, usable from any language | No — Pydantic is Python-specific |
| Dependency footprint | None beyond the SDK itself | Requires the pydantic package |
| Best suited for | Cross-language systems, dynamically-built schemas, minimal dependencies | Python-only applications where developer ergonomics and type safety matter most |
For a Python codebase — which describes the overwhelming majority of applications built directly on this SDK — Pydantic models are generally the more maintainable default, precisely because they eliminate the schema/code synchronization problem this lesson opened with. Raw JSON Schema remains the right choice specifically when a schema needs to be shared with, or generated by, a non-Python system, or when a schema is being built dynamically at runtime from data that doesn't map naturally onto a fixed Python class hierarchy.
Testing Code That Uses Pydantic-Parsed Responses
The dependency-injection testing pattern used throughout this course applies directly here too — a fake response object carrying an already-constructed Pydantic instance, rather than a fake object carrying raw JSON text, lets you test downstream logic without a live API call.
class FakeParsedResponse:
def __init__(self, parsed):
self.output_parsed = parsed
def summarize_order(response) -> str:
"""The logic under test — works with a real or fake parsed Order."""
order = response.output_parsed
item_summary = ", ".join(f"{item.quantity}x {item.name}" for item in order.items)
return f"Order {order.order_id}: {item_summary}, shipping to {order.shipping_address.city}"
def test_summarize_order():
fake_order = Order(
order_id="7723",
items=[LineItem(name="wireless mice", quantity=2), LineItem(name="keyboard", quantity=1)],
shipping_address=ShippingAddress(street="44 Oak Street", city="Denver", zip_code="80202"),
)
fake_response = FakeParsedResponse(fake_order)
result = summarize_order(fake_response)
assert result == "Order 7723: 2x wireless mice, 1x keyboard, shipping to Denver"
print("PASS: summarize_order correctly formats a parsed Order")
test_summarize_order()
Constructing Order(...) directly, without going through client.responses.parse() at all, is possible precisely because a Pydantic model is a normal, independently constructible Python class — this test exercises summarize_order()'s logic using a hand-built instance that's guaranteed valid by Pydantic's own validation at construction time, with no API call, no cost, and no network dependency involved anywhere in the test.
Combining Pydantic Models With Instructions and Few-Shot Examples
Structured outputs are an orthogonal concern to everything Unit 3 covered about prompting — instructions, few-shot examples embedded in the input, and reasoning effort all continue to apply normally alongside a text_format argument, since the schema constrains the shape of the answer while the prompt still shapes its content.
class InvoiceLineItem(BaseModel):
description: str
amount: float
class InvoiceExtraction(BaseModel):
vendor: str
invoice_number: str
line_items: list[InvoiceLineItem]
total: float
response = client.responses.parse(
model="gpt-5.6-luna",
instructions=(
"You extract structured invoice data from raw text. "
"Normalize vendor names to title case. Amounts are always in USD."
),
input=(
"INVOICE from ACME SUPPLY CO. #INV-4471\n"
"1. Widget assembly - $120.00\n"
"2. Shipping - $15.50\n"
"Total: $135.50"
),
text_format=InvoiceExtraction,
)
result = response.output_parsed
print(result.vendor) # "Acme Supply Co." — instructions steered the *content* normalization
print(result.total) # 135.50 — the schema guaranteed the *type*
This example makes the division of labor explicit: the instructions argument is what tells the model how to normalize the vendor name, a purely content-level judgment call no schema could express or enforce on its own; the text_format=InvoiceExtraction argument is what guarantees result.total is a float and result.line_items is a list of correctly-shaped objects, regardless of how the model chooses to interpret the normalization instruction. Neither mechanism can substitute for the other — a schema cannot make the model apply a business rule like title-casing, and an instruction cannot guarantee a field's type or presence the way a schema does.
A Worked Example: Extracting Multiple Related Records From One Document
A realistic extraction task often needs to pull several related records out of a single, longer piece of text at once — worth walking through concretely, since it combines nested models, lists, and enums in a single, coherent schema rather than in isolation as the earlier examples did.
class ActionItem(BaseModel):
description: str
owner: Optional[str] = None
priority: Urgency # reusing the Urgency enum defined earlier in this lesson
class MeetingSummary(BaseModel):
meeting_title: str
attendees: list[str]
action_items: list[ActionItem]
notes = """
Q3 Planning Sync — attendees were Priya, Marcus, and Dana.
We agreed Priya will finalize the budget doc by Friday, high priority.
Marcus will follow up with the vendor about pricing, low priority, no rush.
Someone still needs to update the shared roadmap, medium priority, unassigned for now.
"""
response = client.responses.parse(
model="gpt-5.6-luna",
instructions="Extract a structured summary of this meeting, including all action items and their owners where stated.",
input=notes,
text_format=MeetingSummary,
)
summary = response.output_parsed
for item in summary.action_items:
owner_label = item.owner or "(unassigned)"
print(f"[{item.priority.value}] {item.description} — {owner_label}")
Notice owner: Optional[str] = None correctly captures that the roadmap update has no assigned owner in the source text, without breaking the schema or requiring the model to fabricate a name — exactly the nullable-and-required pattern discussed earlier in this lesson, now applied inside a list of nested objects rather than only at the top level. This nested combination — a list of objects, each with its own optional field and enum-constrained field — is a realistic shape for a genuinely useful extraction feature, and it required no more conceptual machinery than the simpler single-object examples earlier in this lesson, only a slightly more elaborate set of class definitions.
Reusing Models Across an Application
Because a Pydantic model is an ordinary Python class, it can be defined once, in a shared module, and imported anywhere it's needed — in the code that calls parse(), in code that later reads a stored extraction result back out of a database, in tests, and in any type hints elsewhere in the application that describe "a validated order" or "a meeting summary."
# models.py — shared across the application
class LineItem(BaseModel):
name: str
quantity: int
class Order(BaseModel):
order_id: str
items: list[LineItem]
# extraction.py
from models import Order
def extract_order(text: str) -> Order:
response = client.responses.parse(model="gpt-5.6-luna", input=text, text_format=Order)
return response.output_parsed
# storage.py — the same model describes data read back out of a database later
import json
from models import Order
def load_order_from_db(row: dict) -> Order:
return Order.model_validate(json.loads(row["order_data"]))
Order.model_validate() — another built-in Pydantic method, the inverse of model_dump() shown earlier — reconstructs a validated Order instance from a plain dictionary, which is exactly what's needed when reading a previously stored extraction result back out of a database rather than receiving one fresh from parse(). This is a meaningful advantage of the Pydantic approach beyond what this lesson has covered so far: the same model class serves as the schema driving generation, the validated object your application code works with immediately afterward, and the validation layer for that same shape of data anywhere else in the application it shows up later, all from one shared definition.
Common Mistakes
Mixing raw dictionary access with Pydantic attribute access inconsistently across a codebase, using response.output_parsed.name in one place and json.loads(response.output_text)["name"] in another for the same underlying data — pick one approach per extraction task and use it consistently, since mixing them reintroduces exactly the kind of key-name drift risk Pydantic models exist to eliminate.
Forgetting that model_dump() and json.loads(response.output_text) are not interchangeable in general, particularly once enum fields are involved — a Pydantic enum field dumps as its underlying value string via model_dump(), but the object itself (before dumping) is an enum member, not a plain string, and code that assumes one when it has the other can behave subtly incorrectly.
Treating a Field() constraint as an automatic generation-time guarantee without confirming it against current SDK behavior — some constraints are enforced during generation and some are validated only afterward, and conflating the two can lead to code that doesn't handle a ValidationError it should actually expect to see.
Reaching for raw JSON Schema out of habit in a Python codebase, duplicating field names between a hand-written schema and separate consuming code, when a Pydantic model would collapse both into one synchronized definition with no extra dependency cost beyond the widely-used pydantic package.
Best Practices
Default to Pydantic models for structured extraction in any Python codebase, reserving raw JSON Schema specifically for cases needing cross-language portability or fully dynamic, runtime-constructed schemas.
Use str, Enum multiple inheritance for enum fields that also need to behave like plain strings elsewhere in the application, rather than a plain Enum, to avoid friction at every comparison or serialization boundary.
Explicitly catch and distinguish ValidationError from other exceptions when calling parse(), so your application's error handling can tell a genuine data-validation issue apart from a request-level failure, and respond to each appropriately.
Use model_dump() or model_dump_json() for any boundary where a plain dictionary or JSON string is needed, rather than manually reconstructing one field by field, and rely on Pydantic's own serialization behavior for enums, nested models, and optional fields rather than re-implementing it.
Write tests against directly-constructed Pydantic instances, not only against strings of raw JSON, taking advantage of the fact that a Pydantic model is an ordinary, independently testable Python class regardless of whether it happens to have been populated through this SDK's parse() helper.