Pydantic + OpenAI: The Cleanest Way to Get Systematic Results from LLMs

In my latest post on edited output, there are three main ways to get machine-readable answers in LLM. Those are JSON Mode, Function Calling, and OpenAI Scheduled Results. If you haven't read that post yet, it's worth a quick read before this one, as we'll be building directly on it.
So, today, we're going to go ahead and talk about something that changes the way structured output feels in practice. That one Pydantic. Specifically, while OpenAI's Structured Outputs feature ensures that the model returns a valid, schema-compliant JSON, we still need to do things with that JSON on the Python side. In particular, we need to parse it, validate data types, access 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 currently available for building reliable LLM-powered applications in Python. By the end of this post, you'll see exactly why.
what about Pydantic?
Pydantic is a Python library for data validation using type annotations. This means that it allows you to define the shape and types of your data as a Python class, and then ensures that any data you pass it matches that definition. Otherwise, Pydantic raises a clear, self-explanatory error rather than letting bad data silently spread through your system.
Here is the simplest Pydantic model:
from pydantic import BaseModel
class PersonInfo(BaseModel):
name: str
age: int
city: str
This way, we now have a schema that emphasizes that name again city they will always be a string, andage is always a whole number. If someone tries to build a PersonInfo with age="thirty-two" Pydantic will catch it quickly and tell us exactly what went wrong.
But isn't this what we were already doing with Function Calling and Structured Outputs? Yes, but with a very important difference.
On the other hand, with a JSON Mode or Function Call, the model may or may not return a response similar to the schema we had in mind. Even if it gets the schema right, it still returns an empty JSON string, leaving us to manually parse fields, character types, and validate values on the Python side.
On the other hand, Structured Results improves on this by ensuring that the returned JSON always conforms to our defined schema, thanks to forced decoding at the model level. However, it is still a JSON string that we need to handle in Python.
With Pydantic, we define our schema as a Python class, and integration with OpenAI's API means we return. a proper Python object, not a dictionarywith all fields typed and validated automatically. The JSON Schema required by the API is generated in our Pydantic model behind the scenes. We don't have to write for ourselves. In other words, Pydantic is a schema definition layer that sits between our Python code and OpenAI's API.making all planned output clean, safe, and secure.
🍨 DataCream a journal of AI, data, and technology. If you are interested in these topics, register here!
But let's see in practice what we gain by using Pydantic compared to just using Scheduled Extraction.
Here's what the systematic release looked like before the 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. Note how we transfer our Pydantic model response_formatand the OpenAI SDK, which is in charge of generating the JSON Schema, simply makes an API call with strict: Trueand splits the answer back into the correct field PersonInfo thing. In this way, we get type safety, validation, and dot access of fields.
In addition to this, be careful .parse() way, instead of normal .create(). This is because .parse() is a method in the OpenAI SDK specifically designed to work with Pydantic models, managing a complete end-to-end structured output pipeline.
build with Pydantic
1. models and lists
One of the areas where Pydantic really shines is nested data structures. Raw JSON Schema for nested objects is a pain to write, but if you use Pydantic, it defines 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"
I Address the model is embedded ContactInfo naturally, and Pydantic handles full validation of the nested structure. We can then access the fields by writing pure dots, for example, contact.address.city rather than result["address"]["city"]which I suggested KeyError if there is a middle key missing. So, one of the jobs where Pydantic comes in handy is when it needs embedded data structures.
2. validation through the Pydantic field
Another task that Pydantic is very useful for is the validation of returned data. This validation includes checking data types, but it also goes beyond this, as Pydantic also allows to enforce explicit instructions on the actual content of the returned values. Specifically, we can add transparent authentication constraints using Fieldwhich gives the model instructions about what values are acceptable and what are not.
For example, let's assume we have a data return setup about product 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 you have rating field tells the model that valid values are between 1 and 5 max_length=200 you have summary ensures that 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 equivalent to description fields that we have written manually in the Function Calling schemas, we direct the model to what to put in each field.
3. to refuse
Another thing that should be highlighted is that by using .parse()the OpenAI SDK also handles model rejection well. In particular, in the event that the model refuses to complete the request (for example, because the content violates the model's policies), parsed the field will be None as well as refusal field will contain the reason. This allows us to get the planned result, or in the case of rejection, at least inform the application user of the reason why his request was rejected. This is especially important for AI-powered applications that work in production, where we can predict what strange thing a user might ask for.
So, here's how model rejection and 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 cover everything with a practical example. Imagine building a pipeline that processes job submissions and outputs structured information from them. This is exactly the type of work where structured output shines: unstructured input, a well-defined output schema, and a downstream system that needs to enter 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 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 the output code ready for production. Built for a nest SalaryRange the model handles optional salary information in a clean manner, Optional default fields to None gracefully when the information is missing, and every field of the answer is written and verified automatically. The result can be entered directly into the database or passed to the next step of the pipeline without further processing.
In my mind
What I find most satisfying about the combination of Pydantic + OpenAI is how it matches the way a Python developer already thinks. It defines data structures as classes, implements type techniques, and catches type errors early and loudly. All of these are traditionally unexpected parts of any AI program, especially since we had to do it ourselves on the Python side of the AI application. Pydantic acts as a better, tighter connection between the AI model and the rest of our Python code.
✨ Thanks for reading! ✨
If you've made it this far, you may find pialgorithms useful: a platform we've been building that helps teams securely manage organizational information in one place.
Did you like this post? Join me on 💌 A small stake and 💼 LinkedIn
All photos by the author, unless otherwise stated.



