In my latest post on structured outputs, the three main approaches for getting machine-readable responses from an LLM. Those are JSON Mode, Function Calling, and OpenAI’s Structured Outputs. If you haven’t read that post yet, it’s worth a quick read before this one, since we’ll be building directly on top of it.
So, today, we’ll be going one step further and talk about something that changes the way structured outputs feel in practice. That is Pydantic. More specifically, while OpenAI’s Structured Outputs feature guarantees that the model returns valid, schema-conforming JSON, we still need to do stuff with that JSON on the Python side. In particular, we need to parse it, validate the data types, access the fields, handle any unexpected values, and so on. And that’s where Pydantic comes in.
In my opinion, the combination of Pydantic and OpenAI’s Structured Outputs is the cleanest setup available right now for building reliable LLM-powered applications in Python. By the end of this post, you’ll see exactly why.
Contents
what about Pydantic?
Pydantic is a Python library for data validation using type annotations. This means that it lets you define the shape and types of your data as a Python class, and then validates that any data you pass in actually conforms to that description. If it doesn’t, Pydantic raises a clear, descriptive error rather than letting bad data silently propagate through your system.
Here’s the simplest possible Pydantic model:
from pydantic import BaseModel
class PersonInfo(BaseModel):
name: str
age: int
city: str
In this way, we now have a schema that enforces that name and city are always going to be a string, whereasage is always an integer. If someone tries to create a PersonInfo with age="thirty-two" Pydantic will catch it immediately and tell us exactly what went wrong.
But isn’t this just what we were already doing with Function Calling and Structured Outputs? Yes, but with a very important difference.
On one side, with JSON Mode or Function Calling, the model may or may not return a response that matches the schema we had in mind. Even when it does get the schema right, it still returns a plain string of JSON, leaving us to manually parse fields, cast types, and validate values on the Python side.
On the other side, Structured Outputs improves on this by guaranteeing that the returned JSON always conforms to our defined schema, thanks to constrained decoding at the model level. Nonetheless, it is still just a string of JSON that we need to handle ourselves in Python.
With Pydantic, we define our schema as a Python class, and the integration with OpenAI’s API means we get back a proper Python object, not a dictionary, with all fields typed and validated automatically. The JSON Schema that the API needs is generated from our Pydantic model behind the scenes. We never have to write it ourselves. In other words, Pydantic is the schema definition layer that sits between our Python code and OpenAI’s API, making the whole structured output experience cleaner, safer, and more maintainable.
🍨 DataCream is a newsletter on AI, data, and tech. If you are interested in these topics, subscribe here!
But let’s see in practice what we gain from using Pydantic in comparison to just using Structured Outputs.
Here’s what getting structured output looked like before Pydantic integration:
from openai import OpenAI
import json
client = OpenAI(api_key="your_api_key")
# define schema as a raw dictionary
tools = [
{
"type": "function",
"function": {
"name": "extract_person_info",
"strict": True,
"parameters": {
"type": "object",
"properties": {
"name": {"type": "string"},
"age": {"type": "integer"},
"city": {"type": "string"}
},
"required": ["name", "age", "city"],
"additionalProperties": False
}
}
}
]
response = client.chat.completions.create(
model="gpt-4o-mini",
tools=tools,
tool_choice={"type": "function", "function": {"name": "extract_person_info"}},
messages=[
{"role": "user", "content": "Extract info from: 'Maria is 32 years old and lives in Athens.'"}
]
)
# parse manually — we get back a plain dictionary
result = json.loads(response.choices[0].message.tool_calls[0].function.arguments)
print(result["name"]) # "Maria" — but what if the key is missing? KeyError.
print(result["age"]) # might be "32" instead of 32 — type not guaranteed
And here’s the same thing with Pydantic:
from openai import OpenAI
from pydantic import BaseModel
client = OpenAI(api_key="your_api_key")
class PersonInfo(BaseModel):
name: str
age: int
city: str
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{"role": "user", "content": "Extract info from: 'Maria is 32 years old and lives in Athens.'"}
],
response_format=PersonInfo
)
# we get back a proper Python object, fully typed and validated
result = response.choices[0].message.parsed
print(result.name) # "Maria" — always a string
print(result.age) # 32 — always an integer, never a string
print(result.city) # "Athens" — always a string
Obviously, the Pydantic version is shorter, more readable, and safer. Notice how we pass our Pydantic model to response_format, and the OpenAI SDK, which handles generating the JSON Schema, just makes the API call with strict: True, and parses the response back into a proper PersonInfo object. In this way, we get type safety, validation, and dot-notation access to fields.
On top of this, also notice the .parse() method, instead of the usual .create(). This is because .parse() is a method on the OpenAI SDK specifically designed to work with Pydantic models, handling the full structured output pipeline end to end.
building up with Pydantic
1. nested models and lists
One of the places where Pydantic really shines is nested data structures. Raw JSON Schema for nested objects is painful to write, but when using Pydantic, it’s just defining Python classes:
from pydantic import BaseModel
from typing import List
class Address(BaseModel):
street: str
city: str
country: str
class ContactInfo(BaseModel):
name: str
email: str
address: Address
phone_numbers: List[str]
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{
"role": "user",
"content": """Extract contact info from:
'Maria Mouschoutzi, [email protected],
Ermou 15, Athens, Greece.
Phone: +30 210 1234567, +30 697 8901234'"""
}
],
response_format=ContactInfo
)
contact = response.choices[0].message.parsed
print(contact.name) # "Maria Mouschoutzi"
print(contact.address.city) # "Athens"
print(contact.phone_numbers[0]) # "+30 210 1234567"
The Address model is nested inside ContactInfo naturally, and Pydantic handles the full validation of the nested structure. We can then access fields with clean dot notation, as for instance, contact.address.city rather than result["address"]["city"], which can raise KeyError if any intermediate key is missing. So, one of the tasks where Pydantic comes really handy is when needing nested data structures.
2. validation with Pydantic field
Another task on which Pydantic is very useful is the validation of the returned data. This validation includes checking data types, but also goes beyond this, since Pydantic also allows enforcing clear instructions on the actual contents of the returned values. More specifically, we can add explicit validation constraints using Field, giving the model instructions about what values are acceptable and what not.
For example, let’s assume we have a setup returning data about products reviews:
from pydantic import BaseModel, Field
from typing import Optional
class ProductReview(BaseModel):
product_name: str = Field(description="The name of the product being reviewed")
rating: int = Field(ge=1, le=5, description="Rating from 1 to 5 stars")
sentiment: str = Field(description="One of: positive, neutral, negative")
summary: str = Field(max_length=200, description="A brief summary of the review")
verified_purchase: Optional[bool] = Field(default=None, description="Whether this is a verified purchase")
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{
"role": "user",
"content": """Extract a structured review from:
'Absolutely love this coffee machine!
Makes perfect espresso every time.
5 stars, would recommend to anyone.'"""
}
],
response_format=ProductReview
)
review = response.choices[0].message.parsed
print(review.product_name) # "coffee machine"
print(review.rating) # 5
print(review.sentiment) # "positive"
print(review.summary) # "Makes perfect espresso every time."
In particular, the ge=1, le=5 on the rating field tells the model that valid ratings are between 1 and 5. The max_length=200 on the summary ensures we never get a response longer than 200 characters.
In addition, the description fields act as instructions to the model, helping it understand exactly what each field should contain. This is essentially the equivalent of the description fields we wrote manually in Function Calling schemas, guiding the model on what to put in each field.
3. refusals
Another thing worth highlighting is that by using .parse(), the OpenAI SDK also handles model refusals gracefully. More specifically, in the case that the model refuses to complete a request (for example, because the content violates the model’s policies), the parsed field will be None and the refusal field will contain the reason. This allows us to get a structured result, even in the case of refusal, at least informing the application user of the reason why their request was denied. This is of particular significance for AI-powered apps running in production, where we can’t really predict what strange thing the user may request.
So, this is how a model refusal with Pydantic would play out:
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[{"role": "user", "content": "Extract info from the job posting above."}],
response_format=JobPosting
)
message = response.choices[0].message
if message.refusal:
print(f"Model refused: {message.refusal}")
else:
job = message.parsed
print(f"Extracted: {job.job_title} at {job.company_name}")
A real-world example: document information extraction
Let’s put everything together with a more realistic example. Imagine we are building a pipeline that processes job postings and extracts structured information from them. This is exactly the kind of task where structured outputs shine: unstructured input, well-defined output schema, and a downstream system that needs to insert the result into a database.
from pydantic import BaseModel, Field
from typing import List, Optional
from openai import OpenAI
client = OpenAI(api_key="your_api_key")
class SalaryRange(BaseModel):
min_salary: Optional[int] = Field(default=None, description="Minimum salary in USD per year")
max_salary: Optional[int] = Field(default=None, description="Maximum salary in USD per year")
currency: str = Field(default="USD", description="Currency code")
class JobPosting(BaseModel):
job_title: str = Field(description="The job title or role name")
company_name: str = Field(description="The name of the hiring company")
location: str = Field(description="Job location, e.g. 'Athens, Greece' or 'Remote'")
employment_type: str = Field(description="One of: full-time, part-time, contract, freelance")
required_skills: List[str] = Field(description="List of required technical skills")
years_of_experience: Optional[int] = Field(default=None, description="Minimum years of experience required")
salary: Optional[SalaryRange] = Field(default=None, description="Salary range if mentioned")
remote_friendly: bool = Field(description="Whether remote work is allowed")
job_post_text = """
Senior Python Engineer at pialgorithms (Athens, Greece / Remote)
We are looking for an experienced Python developer to join our AI team.
You will work on our document management platform, building intelligent
search and extraction features using LLMs and RAG pipelines.
Requirements:
- 5+ years of Python experience
- Strong knowledge of FastAPI, LangChain, and vector databases
- Experience with OpenAI API and Pydantic
- Familiarity with Docker and cloud deployment (AWS/GCP)
Salary: €60,000 - €85,000 per year
Full-time position. Remote-friendly.
"""
response = client.beta.chat.completions.parse(
model="gpt-4o-mini",
messages=[
{
"role": "system",
"content": "You are an expert at extracting structured information from job postings."
},
{
"role": "user",
"content": f"Extract all relevant information from this job posting:nn{job_post_text}"
}
],
response_format=JobPosting
)
job = response.choices[0].message.parsed
print(f"Title: {job.job_title}")
print(f"Company: {job.company_name}")
print(f"Location: {job.location}")
print(f"Remote: {job.remote_friendly}")
print(f"Skills: {', '.join(job.required_skills)}")
print(f"Experience: {job.years_of_experience} years")
if job.salary:
print(f"Salary: {job.salary.min_salary} - {job.salary.max_salary} {job.salary.currency}")
And the output looks something like this:
Title: Senior Python Engineer
Company: pialgorithms
Location: Athens, Greece / Remote
Remote: True
Skills: Python, FastAPI, LangChain, vector databases, OpenAI API, Pydantic, Docker, AWS, GCP
Experience: 5 years
Salary: 60000 - 85000 EUR
This is production-ready extraction code. The nested SalaryRange model handles the optional salary information cleanly, Optional fields default to None gracefully when information is missing, and every field in the response is typed and validated automatically. The result can be inserted directly into a database or passed to the next step in a pipeline without any additional processing.
On my mind
What I find most satisfying about the Pydantic + OpenAI combination is how well it matches the way a Python developer already thinks. It defines data structures as classes, uses type hints, and catches type errors early and loudly. All these are traditionally the most unpredictable parts of any AI application, mostly because we had to do all of these ourselves manually on the Python side of the AI app. Pydantic works as a better, more robust connection between the AI model and the rest of our Python code.
✨ Thank you for reading! ✨
If you made it this far, you might find pialgorithms useful: a platform we’ve been building that helps teams securely manage organisational knowledge in one place.
Loved this post? Join me on 💌 Substack and 💼 LinkedIn
All images by the author, except where mentioned otherwise.
