Lesson 2 - Validating Inputs with Pydantic
Welcome to Validating Inputs with Pydantic
In the last lesson you learned to write a tight, typed input_schema so Claude reaches for the right tool and provides the right arguments. But a schema is a guide, not a guarantee. Claude reads it and usually obeys it — yet the model can still send a value the schema didn’t fully rule out, or that slips through because you forgot a constraint. When that bad value reaches your function, you get a crash, a wrong answer, or a silent corruption deep inside your code. This lesson adds a hard guarantee on top of the guide. You’ll define a tool’s inputs as a Pydantic model, generate the schema Claude sees from that same model, and validate every argument Claude sends — catching the bad ones before your function ever runs.
By the end of this lesson, you will be able to:
- Define a tool’s inputs as a Pydantic
BaseModelwith typed, constrained fields - Generate the tool’s
input_schemadirectly from the model so the two never drift apart - Validate the input Claude sends by constructing the model, catching
ValidationErrorbefore your code runs - Turn a
ValidationErrorinto a short, clear message you can act on
The mental model for the whole lesson: the schema is the guide, Pydantic is the guard. Let’s begin.
The Input Model: Types, Constraints, and Enums
Start by describing the tool’s inputs as a Pydantic BaseModel. Each field gets a type, and Field(...) lets you attach constraints (like gt=0, “greater than zero”) and a human-readable description. For fixed choices, Literal[...] pins the field to an exact set of allowed values — Pydantic’s equivalent of an enum:
from typing import Literal
from pydantic import BaseModel, Field, ValidationError
class ConvertCurrencyInput(BaseModel):
amount: float = Field(gt=0, description="The amount of money to convert, must be positive.")
from_currency: Literal["JPY", "USD", "EUR"] = Field(description="Currency to convert from.")
to_currency: Literal["JPY", "USD", "EUR"] = Field(description="Currency to convert to.")This model says everything precisely: amount is a positive number, and the two currency fields can only ever be one of "JPY", "USD", or "EUR". Notice that the descriptions read like the field descriptions you wrote by hand in Lesson 1 — that’s not a coincidence, and it’s about to pay off.
Generating the Schema From the Model
Here’s the key move. Instead of writing the input_schema JSON by hand and writing validation code separately — two things that can drift out of sync — you write the model once and generate the schema from it. Calling model_json_schema() produces the exact JSON Schema your tool definition needs:
ConvertCurrencyInput.model_json_schema()returns (abbreviated to the key parts; Claude ignores the title fields Pydantic adds):
{
"type": "object",
"properties": {
"amount": {"type": "number", "exclusiveMinimum": 0,
"description": "The amount of money to convert, must be positive."},
"from_currency": {"type": "string", "enum": ["JPY", "USD", "EUR"],
"description": "Currency to convert from."},
"to_currency": {"type": "string", "enum": ["JPY", "USD", "EUR"],
"description": "Currency to convert to."},
},
"required": ["amount", "from_currency", "to_currency"],
}Look at what came across for free: gt=0 became "exclusiveMinimum": 0, each Literal became an enum, every field description carried over, and all three fields landed in required. This is exactly the kind of tight, typed schema Lesson 1 asked you to write — except you didn’t write it, you derived it. Drop it straight into the tool definition:
TOOL = {
"name": "convert_currency",
"description": "Convert money between currencies. Use this when the user compares prices.",
"input_schema": ConvertCurrencyInput.model_json_schema(),
}Now the schema Claude sees and the model your code validates against are the same object. Change a constraint in the model, and both update together.
One model, one source of truth
Generating the schema from the model means there is exactly one source of truth for what a valid input looks like. If you later decide amount must also be under a million, or you add a "GBP" currency, you change the Pydantic model once — and both the schema Claude sees and the validation your code runs update together. Hand-written schemas drift: someone tightens the validation but forgets the schema, and now Claude is told one thing while your code enforces another. Deriving the schema from the model makes that whole class of bug impossible.
Validating the Input Claude Sends
When the agent loop receives a tool_use block, block.input is a dictionary of the arguments Claude chose. To validate it, you simply construct the model from that dictionary. If the input is valid, you get back a clean, typed object. If it’s bad, Pydantic raises a ValidationError — and it raises it before a single line of your function runs:
# good input -> a validated object
ConvertCurrencyInput(amount=20000, from_currency="JPY", to_currency="USD") # ok
# bad inputs -> ValidationError, raised BEFORE your function runs
ConvertCurrencyInput(amount=-5, from_currency="JPY", to_currency="USD")
# amount: Input should be greater than 0
ConvertCurrencyInput(amount=100, from_currency="GBP", to_currency="USD")
# from_currency: Input should be 'JPY', 'USD' or 'EUR'Those two comment lines are the real messages Pydantic produces. The gt=0 constraint catches the negative amount; the Literal catches the invented "GBP" currency that the schema alone couldn’t stop the model from sending. This is the guard in action: a wrong value never reaches your conversion logic.
The last step is to turn a raised ValidationError into a short, clean string. The error object carries a structured list of problems via exc.errors(); you can join the relevant pieces into one readable line:
try:
ConvertCurrencyInput(**raw_input)
except ValidationError as exc:
message = "; ".join(f"{e['loc'][0]}: {e['msg']}" for e in exc.errors())
# e.g. "from_currency: Input should be 'JPY', 'USD' or 'EUR'"Each entry in exc.errors() has a loc (which field) and a msg (what went wrong), so the joined string names the offending field and explains the problem in plain language. Hold on to that message — in the next lesson you’ll feed it straight back to Claude so the model can correct itself and try again.
Practice Exercises
Exercise 1: Add a constrained field
Extend ConvertCurrencyInput with a rounding field that controls how many decimal places the result is rounded to — an integer between 0 and 4. How would you add it to the model?
Hint
Add rounding: int = Field(ge=0, le=4, description="Decimal places to round the result to."). The ge (greater-than-or-equal) and le (less-than-or-equal) constraints bound the value on both ends, and typing it as int rejects fractional values. Because you added it to the model, it automatically appears in the generated schema and gets validated on every call — no separate edits needed.
Exercise 2: Predict the generated schema
Without running it, what will model_json_schema() produce for a field defined as currency: Literal["JPY", "USD", "EUR"]? Name the JSON Schema keys you expect to see.
Hint
A Literal of strings becomes {"type": "string", "enum": ["JPY", "USD", "EUR"]} — Pydantic maps the literal values onto a JSON Schema enum, with the type inferred from the values. Any description you attached via Field comes across too, and the field appears in the top-level required list. This is exactly the tight, enum-constrained schema Lesson 1 told you to write by hand — now derived automatically.
Exercise 3: What happens on amount=-5?
Claude sends {"amount": -5, "from_currency": "JPY", "to_currency": "USD"} to your tool. Trace what happens when you construct ConvertCurrencyInput(**raw_input), and why this is better than letting the value reach your conversion function.
Hint
Construction raises a ValidationError because -5 violates gt=0, with the message amount: Input should be greater than 0. The error fires before your conversion logic runs, so a negative amount never produces a nonsensical result or a crash deep inside your code. You catch the error at the boundary, turn it into a clean message, and (next lesson) hand it back to Claude to fix.
Summary
A tight input_schema guides Claude, but it can’t guarantee valid inputs — the model can still send a wrong value. Pydantic provides the guard. You define a tool’s inputs as a BaseModel with typed fields, Field(...) constraints like gt=0, and Literal[...] for fixed choices. You then generate the tool’s input_schema straight from that model with model_json_schema(), so the schema Claude sees and the validation your code runs are one and the same — they can never drift apart. When a tool_use block arrives, you validate block.input by constructing the model: good input becomes a clean typed object, and bad input raises a ValidationError before your function runs. Finally, you turn that error into a short, clear message — ready to feed back to Claude in the next lesson.
Key Concepts
- Pydantic input model — a
BaseModelwhose typed fields,Field(...)constraints, andLiteral[...]enums define exactly what a valid input is. - Schema from model —
model_json_schema()generates theinput_schemafrom the model, giving you one source of truth so the guide and the guard never drift. - Validation by construction — building the model from
block.inputvalidates it, raisingValidationErroron bad input before your code runs. - Clean error message —
exc.errors()carries each field (loc) and reason (msg), which you join into a short readable string.
Why This Matters
Validation at the tool boundary is what turns a “usually works” agent into a reliable one. The schema shapes what Claude tries to send; Pydantic enforces what your code will accept. Generating the schema from the model removes the most insidious failure mode of hand-written tools — the schema and the validation silently disagreeing. And catching a ValidationError early means a bad value never corrupts your logic or crashes mid-run. But catching the error is only half the story: an agent that simply fails on bad input isn’t much of an agent. In the next lesson you’ll close the loop — handing that clean error message back to Claude so it can repair its own call and try again.
Next Steps
Continue to Lesson 3 - Errors and the Repair Loop
Feed validation errors back to Claude so it can correct its own tool calls and try again.
Back to Module Overview
Return to the Designing Tools module overview
Continue Building Your Skills
You can now define a tool’s inputs as a Pydantic model, generate the schema Claude sees from that single source of truth, and validate every argument Claude sends — turning bad inputs into clean, readable errors before your code runs. Next you’ll put those errors to work: feeding them back to Claude inside the agent loop so the model can repair its own call and recover, instead of the whole run failing on one bad value.