OpenAI structured output chokes on the Pydantic models you already have
Automation Engineering · By Caleb Sakala · April 24, 2026
The OpenAI structured output tutorial makes it look clean. Define a Pydantic model, pass it to client.beta.chat.completions.parse(), get typed data back. Then you try a model from your real codebase, the one with Optional fields and default values and a Literal enum, and the API returns Invalid schema for response_format. The error message points at a schema path that doesn't obviously map to your Python code. You're left commenting out fields one at a time until something works.
This happens because Pydantic generates full JSON Schema. OpenAI structured output accepts a stripped-down subset. The gap between those two things is where most production debugging time goes.
The tutorial version works because tutorial models are toy models — openai structured output pydantic basics
OpenAI's structured output uses constrained decoding to restrict token generation at inference time so only schema-valid tokens can appear. This guarantees format validity, but the tradeoff is a simpler schema language than what JSON Schema supports.
The specific restrictions, per OpenAI's April 2026 documentation: all fields must be marked required. No default values. No additionalProperties (must be false). No minLength, maxLength, minimum, maximum, or pattern constraints. No recursive schemas. The anyOf keyword can't appear at the root level.
A tutorial model like this passes:
class MovieReview(BaseModel):
title: str
rating: int
summary: str
A production model like this does not:
class MovieReview(BaseModel):
title: str
rating: int = Field(ge=1, le=10)
summary: str
spoiler_free: bool = True
sequel: Optional["MovieReview"] = None
That second model has four incompatibilities: a default value on spoiler_free, ge/le constraints on rating, an Optional field generating a non-required property, and a recursive self-reference. OpenAI rejects the schema before the model even sees the prompt.
Three openai structured output pydantic errors and how to fix them
Default values are the most frequent cause. The fix is wrapping defaulted fields in a Union with None. Instead of spoiler_free: bool = True, write spoiler_free: Union[bool, None] and remove the default. After parsing, check for None and apply the default in your application code.
from typing import Union
from pydantic import BaseModel
class MovieReviewWire(BaseModel):
title: str
rating: int
summary: str
spoiler_free: Union[bool, None]
# After receiving the response:
if review.spoiler_free is None:
review.spoiler_free = True
Optional fields follow the same pattern. OpenAI requires every field in the schema to be present, so Optional[str] with a default of None must become Union[str, None] with no default. The difference is subtle in Python's type system but meaningful in the generated JSON Schema: Pydantic renders Optional[str] fields as non-required when they have defaults, and OpenAI rejects non-required fields outright.
Constraints are the sneakiest failure. The ge=1, le=10 constraint on rating generates minimum and maximum in the JSON Schema. Some versions of the OpenAI Python client strip these silently. Others reject them. Remove constraints from the model you send to OpenAI and validate after parsing instead. The rating field becomes rating: int, and you check 1 <= review.rating <= 10 yourself once the API responds.
This creates a pattern worth spelling out: the Pydantic model you send to OpenAI should not be the same model you use in your application. You need two models. A stripped-down "wire model" for the API and a full "domain model" for your code. The wire model defines what OpenAI generates. The domain model defines what your application expects. Mapping between them is where the real validation lives.
Refusals are the failure mode nobody logs — an openai structured output pydantic blind spot
Everything above deals with schema rejection, the error you see before the API call succeeds. Refusals are worse. They happen after a successful API call, inside a response that looks normal.
When OpenAI's safety system flags a prompt while structured output is enabled, the API returns HTTP 200 with a refusal field containing plain text. The parsed field comes back as None. If your code accesses response.choices[0].message.parsed.title without checking for the refusal, you get an AttributeError at runtime. Pydantic validation never ran because there was no JSON to validate.
Pydantic-AI (the framework) had an open bug on this exact scenario through early 2026, documented in GitHub issue #4310. The framework attempted to validate the refusal text as structured JSON, producing a ValidationError that masked the real problem entirely.
response = client.beta.chat.completions.parse(
model="gpt-4o",
messages=messages,
response_format=MovieReviewWire,
)
message = response.choices[0].message
if message.refusal:
log.warning(f"Structured output refused: {message.refusal}")
return default_review()
review = message.parsed
Checking for message.refusal before accessing message.parsed is the minimum viable fix. In automated workflows processing thousands of requests, refusals need their own monitoring path. A 0.1% refusal rate across 50,000 daily API calls means 50 silent failures per day.
(This is, to be honest, a design problem in the API itself. The response returns the same 200 status code whether the model generated valid structured data or refused entirely. Every consumer of the API has to build the same refusal-checking boilerplate. The OpenAI Python SDK could handle this at the client level, and the fact that it doesn't is a source of real production bugs.)
Schema sanitizers trade one problem for another
The ecosystem response to these incompatibilities has been schema sanitizers: libraries that take a Pydantic model and strip it down to an OpenAI-compatible subset. Fractional AI published one. Developer aviad rozenhek published another. The approach gets past the schema rejection error.
It also introduces a different risk.
When a sanitizer removes minimum: 1, maximum: 10 from a rating field, the model can return rating: -500 and the API considers that valid structured output. When it converts a defaulted field to a nullable union, the model might return None for something that should almost always be populated. The sanitizer has no way to know that None is contextually wrong.
Sanitizers understand JSON Schema syntax. They don't understand domain semantics. These are different things, and the gap grows wider as models get more complex.
A better approach for workflows that run unattended: write the wire model by hand. Make it match what you want OpenAI to generate, not what your application needs internally. Then map between wire model and domain model explicitly. More code, but the mapping is visible and testable.
In automation workflows built on platforms like Chase Agents, this boundary shows up at every step that receives LLM output and routes it to downstream actions. Each step defines an input schema, so when an LLM returns rating: -500 through a sanitized schema, the next step's validation catches it at the boundary. The failure surfaces as a clear schema violation, not as a silent data corruption three steps deep in a billing calculation.
More openai structured output pydantic pitfalls
Recursive models require restructuring. A Comment model that references itself for threaded replies must be flattened to a Comment with a parent_id: Union[str, None] field, trading elegance for compatibility. Some teams cap nesting depth by defining Comment, CommentL1, and CommentL2 as separate models. Neither approach is fun, but both work.
The additionalProperties constraint catches teams migrating from JSON mode to structured output. JSON mode allowed extra fields in the response. Structured output with strict: true requires additionalProperties: false, meaning the model produces exactly the fields in your schema. Code that relied on extra fields the model "helpfully" included will break.
Enum handling has its own quirks. Literal["draft", "published", "archived"] works. Python Enum subclasses work if they're string-backed. IntEnum with custom values sometimes generates schemas that OpenAI rejects, depending on your Pydantic version and how the enum serializes. When this happens, the error message references a schema path that requires careful reading to trace back to the offending Python class.
Teams switching providers hit these problems from a different angle. A Pydantic model that worked with Anthropic's tool_use structured output may fail with OpenAI because the supported JSON Schema subsets differ between providers. Chase Agents handles provider-switching per automation step, which means a workflow that passed validation with Claude can break when a step gets reassigned to GPT-4o. The wire model has to be written against the strictest provider's constraints, or maintained separately per provider.
Where this leaves your existing models
The Pydantic model you wrote for your API layer, the one with careful defaults, validation constraints, and a recursive type, took real design effort. OpenAI's structured output supports almost none of those features. No sanitizer library fixes that mismatch without hiding something. The 200-line Pydantic model that powers your domain logic and the 40-line wire model that talks to OpenAI should probably never be the same class. Treating them as separate artifacts, with an explicit mapping layer between them, is less elegant than passing your existing model straight to parse(). But it is the version that works at 3 AM when nobody is watching the logs.