Use Pydantic Models

Su Suriya Ravichandran Updated 10 Nov 2026
8 min read ·Lesson 104 of 200

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:

JobHow
Defines the schema sent to ClaudeThe SDK converts the model to a JSON Schema
Validates the responseThe SDK checks the response against the model, including constraints the API cannot enforce
Gives you typed dataparsed_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 annotationSchemaNotes
str, int, float, boolstring, integer, number, boolean
list[str]array of strings
Literal["a", "b"]enumFixed choices
A nested BaseModelA nested objectReusable structures
str | NoneA union with nullCounts toward the limit of 16 union-type parameters
A field with a defaultAn optional propertyCounts 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:

  1. removes unsupported constraints (for example minimum, maximum, minLength, maxLength) from the schema it sends,
  2. adds them to the field's description, so Claude still sees them, and
  3. 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 the aspects field.
  • Raising ValueError makes 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

  1. Aspect is a nested model: each aspect has a fixed name, a fixed opinion, and a short supporting quote, which grounds the analysis in the review's own words (Lesson 106).
  2. ReviewAnalysis combines Literal fields, a constrained integer, a list of nested models, and a nullable string. suggestion: str | None has no default, so it is required but nullable: it is always present, and None when there is no suggestion.
  3. 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 against ge=1, le=5.
  4. unique_aspects is a field validator that rejects duplicate aspects, a rule that the schema cannot express.
  5. messages.parse() sends the schema, validates the response, and returns a ReviewAnalysis instance. 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.
  6. stop_reason is checked before the result is used, as always.
  7. 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 Literal for fixed choices, and nested models for structure.
  • Prefer required fields, and use X | None only 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

  1. Add a would_recommend: bool field to ReviewAnalysis, and test it with both reviews.
  2. Add a model validator (@model_validator(mode="after")) that rejects a "positive" sentiment with a rating below 3.
  3. Print the full output of transform_schema(ReviewAnalysis). Where did additionalProperties: false appear?
  4. Explain why suggestion is str | None without a default, instead of str | None = None.
Show answers for steps 2 and 4

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.

0 Comments

Reviewed before they appear

No comments yet.

Anthropic SDK Using Python
Anthropic SDK Intro Claude API Intro SDK Requirements Install Anthropic SDK Upgrade the SDK Create an API Key Set API Keys Use Environment Variables Import Anthropic Create a Client Send Your First Message Choose a Claude Model Set Max Tokens Read a Response Print Response Text Understand Message Blocks Use System Prompts Send User Messages Set Temperature Handle Stop Reasons Messages API Intro Create Messages Use Message Roles Send Conversation History Continue a Conversation Build a Chat Loop Reset Chat History Use Multiple Turns Inspect Message IDs Read Usage Data Write Clear Prompts Set a System Prompt Add Context to Prompts Use Prompt Templates Add Examples to Prompts Request JSON Output Use XML Tags Control Response Length Improve Prompt Quality Avoid Prompt Injection Read Text Blocks Read Content Blocks Check Stop Reasons Count Input Tokens Count Output Tokens Count Tokens Before Sending Limit Output Tokens Use Stop Sequences Read Request IDs Parse API Responses Streaming Intro Stream Text Output Use Stream Events Use Stream Helpers Get Final Messages Stream Async Responses Handle Stream Errors Build a Live Chat Stream Long Responses Close Streams Safely Build a Chatbot Keep Chat History Manage Chat Sessions Add Chat Memory Build a CLI Chatbot Build a Web Chatbot Use Flask with Claude Use FastAPI with Claude Return JSON from an API Add Chat Error Messages Async SDK Intro Create an Async Client Send Async Messages Use asyncio Run Parallel Requests Stream Async Text Handle Async Errors Set Async Timeouts Close Async Clients Choose Sync or Async Tool Use Intro Define a Tool Create Tool Schemas Send Tools to Claude Read Tool Calls Run Python Tools Return Tool Results Continue After Tool Use Control Tool Choice Handle Tool Errors Use the beta_tool Decorator Create a Tool Runner Add Tool Docstrings Validate Tool Inputs Use Multiple Tools Limit Tool Calls Log Tool Activity Build a Weather Tool Build a Search Tool Build a Calculator Tool Structured Output Intro Request JSON Data Validate JSON Output Use Pydantic Models Handle Invalid JSON Extract Fields from Text Generate Typed Data Use Image URLs Send Base64 Images Read PDFs with Claude Use Output Schemas Build Data Extraction Test Structured Responses Send Image Inputs Upload Files Use File IDs Process Text Files Summarize Documents Extract Table Data Handle File Errors Read Cache Usage Reduce Repeated Input Costs Debug Cache Misses Prompt Caching Intro Enable Prompt Caching Cache System Prompts Cache Long Context Test Cache Performance Use Cache Breakpoints Cache Best Practices Message Batches Intro Create a Message Batch Set Batch IDs Check Batch Status List Message Batches Get Batch Results Process Batch Outputs Handle Failed Batch Items Use Batches for Bulk Tasks Compare Batches and Requests Handle API Errors Handle Rate Limits Retry Failed Requests Set Request Timeouts Handle Network Errors Use Exponential Backoff Build Reliable API Calls Use Extended Thinking Log API Errors Track Request IDs Paginate API Results Set Thinking Budgets Read Thinking Blocks Use Tool Search Use Web Search Tools Use Computer Tools Use Citations Use Token Counting Read Raw Responses Set Custom Headers Use Claude on AWS Bedrock Use Claude on Vertex AI Use Claude on Microsoft Foundry Configure Cloud Credentials Choose a Cloud Provider Compare API Integrations Configure Provider Clients Handle Cloud Errors Use Cloud SDK Extras Deploy Cloud Apps Protect API Keys Use a .env File Ignore Secrets in Git Validate User Input Reduce Prompt Injection Limit Tool Permissions Avoid Unsafe Tool Calls Sanitize Tool Results Set Usage Limits Review Security Logs Test SDK Functions Mock API Responses Test Error Handling Test Streaming Code Measure Token Usage Track API Costs Configure Logging Deploy with Docker Deploy a FastAPI App Monitor Production Apps Build a Claude Chatbot Build a Text Summarizer Build a Code Explainer Build a JSON Extractor Build a PDF Assistant Build a Streaming Chat App Build a Tool-Calling Agent Build a Batch Processor Build a Research Assistant Build a Claude API Dashboard
Ask about this post
AI Ask about this post

Ask questions about Use Pydantic Models and get answers drawn from it.

Signed-in readers only.