Use Pydantic Models
Why Pydantic Models
In Python, the recommended way to use structured outputs is to describe the output as a Pydantic model and pass it to client.messages.parse() (Lessons 36 and 101). One class then does three jobs:
| Job | How |
|---|---|
| Defines the schema sent to Claude | The SDK converts the model to a JSON Schema |
| Validates the response | The SDK checks the response against the model, including constraints the API cannot enforce |
| Gives you typed data | parsed_output is an instance of your model, with attributes, type hints, and editor support |
According to the documentation, messages.parse() transforms the model's schema, sends it as output_config.format, validates the response, and returns the parsed model in parsed_output.
from anthropic import Anthropic
from pydantic import BaseModel
class Contact(BaseModel):
name: str
email: str
response = Anthropic().messages.parse(
model="claude-opus-5-5",
max_tokens=1024,
output_format=Contact,
messages=[{"role": "user", "content": "Reach Priya at priya@example.com"}],
)
contact = response.parsed_output # A Contact instance
print(contact.name, contact.email) # Priya priya@example.com
response is a full message (with stop_reason, usage, and content) plus the extra parsed_output attribute.
Mapping Python Types to Schemas
| Python annotation | Schema | Notes |
|---|---|---|
str, int, float, bool | string, integer, number, boolean | |
list[str] | array of strings | |
Literal["a", "b"] | enum | Fixed choices |
A nested BaseModel | A nested object | Reusable structures |
str | None | A union with null | Counts toward the limit of 16 union-type parameters |
| A field with a default | An optional property | Counts toward the limit of 24 optional parameters |
Field Descriptions
Field(description=...) becomes the property's description, which guides Claude's content, exactly like tool parameter descriptions (Lesson 93):
from pydantic import Field
class Summary(BaseModel):
headline: str = Field(description="One sentence, under 12 words")
key_points: list[str] = Field(description="3 to 5 short bullet points")
Nested Models
Models can contain other models, and lists of models:
class Aspect(BaseModel):
name: Literal["content", "instructor", "pace", "platform", "price"]
opinion: Literal["positive", "negative", "neutral"]
class ReviewAnalysis(BaseModel):
aspects: list[Aspect]
Nested models keep complex outputs organized, and each nested class can be reused elsewhere.
Constraints: Sent as Guidance, Enforced in Validation
Pydantic constraints, such as Field(ge=1, le=5), min_length, and max_length, are useful but not all of them are enforced by the API. According to the documentation, the SDK:
- removes unsupported constraints (for example
minimum,maximum,minLength,maxLength) from the schema it sends, - adds them to the field's description, so Claude still sees them, and
- validates the response against your original model with all its constraints.
rating: int = Field(ge=1, le=5, description="Overall rating from 1 to 5")
Claude receives an integer field whose description mentions the 1–5 range, and your code receives a rating that has been checked against it. If a response violates a constraint, parsing fails with a validation error instead of returning bad data (Lesson 105 handles that case).
To see exactly what is sent, pass the model to transform_schema() (Lesson 102):
from anthropic import transform_schema
print(transform_schema(ReviewAnalysis))
Custom Validators
Pydantic validators express rules that types and constraints cannot, and run when the response is validated:
from pydantic import field_validator
class ReviewAnalysis(BaseModel):
aspects: list[Aspect]
@field_validator("aspects")
@classmethod
def unique_aspects(cls, aspects: list[Aspect]) -> list[Aspect]:
names = [a.name for a in aspects]
if len(names) != len(set(names)):
raise ValueError("each aspect may appear only once")
return aspects
@field_validator("aspects")runs the method for theaspectsfield.- Raising
ValueErrormakes validation fail with that message. - Returning the value (possibly modified) accepts it.
Validators are the Pydantic equivalent of the business rules in Lesson 103.
Practical Example: Course Review Analysis
This example analyzes student reviews into a nested, constrained, validated model, and shows what the SDK sends to the API.
"""review_analysis.py: structured review analysis with nested Pydantic models."""
import json
from typing import Literal
from anthropic import Anthropic, transform_schema
from pydantic import BaseModel, Field, ValidationError, field_validator
client = Anthropic()
class Aspect(BaseModel):
name: Literal["content", "instructor", "pace", "platform", "price"]
opinion: Literal["positive", "negative", "neutral"]
quote: str = Field(description="A short quote from the review supporting this opinion, under 12 words")
class ReviewAnalysis(BaseModel):
sentiment: Literal["positive", "negative", "mixed"]
rating: int = Field(ge=1, le=5, description="Overall rating implied by the review, from 1 to 5")
aspects: list[Aspect] = Field(description="Only aspects the review actually mentions")
suggestion: str | None = Field(description="The student's main suggestion, or null if none")
@field_validator("aspects")
@classmethod
def unique_aspects(cls, aspects: list[Aspect]) -> list[Aspect]:
names = [aspect.name for aspect in aspects]
if len(names) != len(set(names)):
raise ValueError("each aspect may appear only once")
return aspects
REVIEWS = [
"Loved the Django course! Arjun explains really clearly. The pace in week 3 was too fast though, "
"maybe add an extra practice week.",
"Videos keep buffering and the price is too high for what you get.",
]
def analyze(review: str) -> ReviewAnalysis | None:
try:
response = client.messages.parse(
model="claude-opus-5-5",
max_tokens=2048,
output_format=ReviewAnalysis,
messages=[{"role": "user", "content": f"Analyze this course review.\n<review>\n{review}\n</review>"}],
)
except ValidationError as error:
print(f" [response failed validation: {error.errors()[0]['msg']}]")
return None
if response.stop_reason != "end_turn":
print(f" [no result: stop_reason={response.stop_reason}]")
return None
return response.parsed_output
if __name__ == "__main__":
sent = transform_schema(ReviewAnalysis)
print("rating as sent to the API:", json.dumps(sent["properties"]["rating"]))
for review in REVIEWS:
result = analyze(review)
if result:
print(f"\n{result.sentiment} ({result.rating}/5)")
for aspect in result.aspects:
print(f" {aspect.name:<10} {aspect.opinion:<8} \"{aspect.quote}\"")
print(f" suggestion: {result.suggestion}")
Example output (the transformed description text and analyses may differ slightly):
rating as sent to the API: {"type": "integer", "description": "Overall rating implied by the review, from 1 to 5 {minimum: 1, maximum: 5}"}
mixed (4/5)
instructor positive "Arjun explains really clearly"
pace negative "The pace in week 3 was too fast"
content positive "Loved the Django course!"
suggestion: Add an extra practice week.
negative (2/5)
platform negative "Videos keep buffering"
price negative "the price is too high for what you get"
suggestion: None
How It Works
Aspectis a nested model: each aspect has a fixedname, a fixedopinion, and a short supportingquote, which grounds the analysis in the review's own words (Lesson 106).ReviewAnalysiscombinesLiteralfields, a constrained integer, a list of nested models, and a nullable string.suggestion: str | Nonehas no default, so it is required but nullable: it is always present, andNonewhen there is no suggestion.rating: int = Field(ge=1, le=5, ...): the printed transformed schema shows that the API receives a plain integer, with the range added to the description. The SDK still validates the response againstge=1, le=5.unique_aspectsis a field validator that rejects duplicate aspects, a rule that the schema cannot express.messages.parse()sends the schema, validates the response, and returns aReviewAnalysisinstance. If validation fails, for example because of a duplicate aspect, the error is caught here instead of crashing the program. Lesson 105 shows how to retry instead.stop_reasonis checked before the result is used, as always.- The typed result is easy to use:
aspect.name,result.rating, and so on, with autocompletion in your editor.
Common Mistakes
Too Many Optional or Nullable Fields
What happens: large schemas exceed the complexity limits (24 optional parameters and 16 union-type parameters per request), and requests fail with "Schema is too complex for compilation".
Fix: make fields required where possible, and use nullable fields only where values can genuinely be missing (Lesson 108).
Expecting Constraints to Be Enforced During Generation
What happens: a value outside ge/le causes a validation error after generation, which can surprise you.
Fix: describe limits clearly, and handle validation errors with a retry (Lesson 105).
Fields Without Descriptions
What happens: Claude guesses what ambiguous fields mean, for example whether rating is out of 5 or 10.
Fix: describe every non-obvious field.
Asking for Reasoning in a Field
What happens: according to the documentation, a property that asks for the model's thinking or step-by-step reasoning might lead to a refusal.
Fix: ask for a short explanation or supporting quote instead.
Using parsed_output Without Checking stop_reason
What happens: a refusal or truncation is treated as a valid result.
Fix: check stop_reason first.
Best Practices
- Use
messages.parse()with Pydantic models in Python. - Describe fields with
Field(description=...). - Use
Literalfor fixed choices, and nested models for structure. - Prefer required fields, and use
X | Noneonly where values may be missing. - Add constraints and validators for rules that matter, and handle validation errors.
- Inspect the sent schema with
transform_schema(). - Ask for short explanations or quotes, not reasoning, in fields.
Exercise
- Add a
would_recommend: boolfield toReviewAnalysis, and test it with both reviews. - Add a model validator (
@model_validator(mode="after")) that rejects a"positive"sentiment with a rating below 3. - Print the full output of
transform_schema(ReviewAnalysis). Where didadditionalProperties: falseappear? - Explain why
suggestionisstr | Nonewithout a default, instead ofstr | None = None.
Step 2:
from pydantic import model_validator
class ReviewAnalysis(BaseModel):
...
@model_validator(mode="after")
def sentiment_matches_rating(self) -> "ReviewAnalysis":
if self.sentiment == "positive" and self.rating < 3:
raise ValueError("a positive review must have a rating of at least 3")
return self
mode="after" runs after all fields are validated, so the method can compare several fields with each other.
Step 4: without a default, the field is required: it always appears in the output, either as a string or as null. That makes the output's shape predictable, and makes Claude decide explicitly whether a suggestion exists. With = None, the field would be optional: Claude could omit it, and it would count toward the optional-parameter limit as well as the union-type limit.