Sam Austinon October 23, 202616 min read

Validating Tool Call Outputs with Pydantic

Validating Tool Call Outputs with Pydantic
Contents

Figure 1: Validating Tool Call Outputs with Pydantic — validate the arguments, then let the tool act

Your model decides to call refund_order with an amount of "fifty dollars", an order ID that does not exist, and a surprise extra field called override_approval. Your code happily passes the dictionary to the payment API.

That is the moment when a language model's output stops being text and starts being an action. Anything that turns model output into an action deserves a checkpoint, and Pydantic makes a very good one.

This tutorial shows you how to validate tool calls with Pydantic in both directions: the arguments the model sends to your tool, and the results your tool sends back. I researched current practice in October 2026, and numbered brackets like [1] point to the sources at the end. The code is my own and untested, written for Pydantic v2, so run it against your installed version.

Two Things to Validate

"Tool call outputs" can mean two different things, and you need to check both:

Direction What to validate Why
Model output → your tool The function name and JSON arguments Nothing executes until the shape is right
Your tool → the model The data your function returns Malformed or sensitive data never reaches the model

One guide's rule of thumb fits both: treat every LLM response as untrusted text until application code validates it [2]. Another adds the rule that matters most: never execute a tool call, payment or other external action before validation succeeds [2].

Why Provider-Side Guarantees Aren't Enough

Modern APIs offer schema-constrained generation, and it helps a lot. But it does not cover everything:

  • JSON mode guarantees syntactically valid JSON, and structured outputs enforce schema adherence [3]. They still cannot check business rules, like "the refund cannot exceed the order total."
  • A tool call can have a valid name and arguments that do not match your model [3]. You can also get extra prose around JSON, or Markdown fences around the payload [3].
  • Not every provider or model offers strict modes, and schemas support only a subset of validation rules.

The practical conclusion: use provider-side strictness when you have it, and validate in your own code anyway. Pydantic remains your local trust boundary, because the API generates text and your application decides whether that text counts as data [2].

Step 1: Define One Contract Per Tool

Describe each tool's arguments as a Pydantic model. The same model then generates the JSON Schema for the tool definition and validates the incoming call, so the two can never drift apart [3].

from typing import Annotated, Literal

from pydantic import BaseModel, ConfigDict, Field, field_validator


class RefundArgs(BaseModel):
    model_config = ConfigDict(extra="forbid")   # reject surprise fields

    order_id: Annotated[str, Field(pattern=r"^ORD-\d{6}$")]
    amount_cents: Annotated[int, Field(gt=0, le=50_000)]
    reason: Literal["damaged", "late", "wrong_item", "other"]
    note: str | None = Field(default=None, max_length=500)

    @field_validator("note")
    @classmethod
    def clean_note(cls, v):
        return v.strip() if v else v


# One source of truth: generate the tool's JSON Schema from the model
parameters_schema = RefundArgs.model_json_schema()

A few choices here deserve attention:

  • extra="forbid" mirrors the JSON Schema idea of additionalProperties: false, which strict tool schemas in OpenAI's function-calling docs also require [3].
  • Literal types turn free text into a closed set of allowed values.
  • Bounds and patterns (gt, le, pattern, max_length) put your limits in one visible place.
  • Money as integer cents avoids floating-point surprises. Model fields with Field(allow_inf_nan=False) if you must accept floats.

Step 2: Validate the Raw JSON String

Provider SDKs often hand you tool arguments as a JSON string. Pydantic v2 can parse and validate in one step with model_validate_json [1][2]:

from pydantic import ValidationError

raw = '{"order_id": "ORD-123456", "amount_cents": 1200, "reason": "damaged"}'

try:
    args = RefundArgs.model_validate_json(raw)
except ValidationError as exc:
    print(exc.errors())

Pydantic's own guide shows this pattern against tool call arguments, and it points out that models sometimes wrap JSON in Markdown fences or surround it with prose, which breaks naive parsing [1]. Keep the raw string until validation finishes [2].

A ValidationError can represent invalid JSON syntax, a missing field, a wrong type, an unsupported enum value, or an extra field [2]. That single exception type simplifies your error handling.

Strict Mode and Coercion

Pydantic's default (lax) mode converts compatible values, like turning the string "5" into the integer 5. That is friendly, and it can hide model mistakes. One tutorial validates with strict=True for exactly this reason [2]. Decide per tool:

  • Strict: use it for money, IDs and anything where you want to catch sloppy output.
  • Lax: use it where harmless coercion saves a pointless retry.

Test your choice with real model output, since behaviour can differ across Pydantic versions.

Step 3: Add Business Rules

Schema checks catch shape problems. Real tools also need rules that depend on context, like "this amount cannot exceed the order total." Pydantic validators can read context you pass in:

from pydantic import ValidationInfo, model_validator


class RefundArgsChecked(RefundArgs):
    @model_validator(mode="after")
    def amount_within_order(self, info: ValidationInfo):
        order = (info.context or {}).get("order")
        if order and self.amount_cents > order["total_cents"]:
            raise ValueError("amount_cents exceeds the order total")
        return self


# order = load_order(...)  # fetch it after a first-pass check on order_id
args = RefundArgsChecked.model_validate_json(raw, context={"order": order})

Keep two layers separate in your head. Shape validation needs no outside data, and business validation needs the database. Run the cheap layer first, so you only fetch the order when the ID looks sane.

Step 4: Turn Errors Into Useful Feedback

When validation fails, the model needs a clear, compact message, and your logs need the details. Build a summary that includes the field, the problem and the error type, and leaves out raw inputs:

def error_summary(exc: ValidationError) -> list[dict]:
    return [
        {
            "field": ".".join(str(p) for p in e["loc"]),
            "problem": e["msg"],
            "type": e["type"],
        }
        for e in exc.errors(include_url=False, include_input=False)
    ]

Check the errors() options in your Pydantic version, since available arguments have changed across releases. Returning compact errors keeps the conversation short and avoids echoing sensitive input back into the model's context.

Step 5: A Safe Dispatcher

Put the pieces together in one function that every tool call passes through:

from dataclasses import dataclass
from typing import Callable


class RefundResult(BaseModel):          # only fields the model may see
    refund_id: str
    status: Literal["approved", "pending", "rejected"]
    amount_cents: int


@dataclass
class ToolSpec:
    args_model: type[BaseModel]
    result_model: type[BaseModel]
    fn: Callable


REGISTRY = {
    "refund_order": ToolSpec(RefundArgs, RefundResult, lambda a: issue_refund(a)),
}


def run_tool_call(name: str, raw_args: str) -> dict:
    spec = REGISTRY.get(name)
    if spec is None:
        return {"ok": False, "error": f"Unknown tool: {name}"}

    try:
        args = spec.args_model.model_validate_json(raw_args)
    except ValidationError as exc:
        return {"ok": False, "error": "Invalid arguments", "details": error_summary(exc)}

    # authorization checks belong here, before the tool runs

    raw_result = spec.fn(args)

    try:
        result = spec.result_model.model_validate(raw_result)
    except ValidationError:
        # log the details internally; don't forward them to the model
        return {"ok": False, "error": "Tool returned an unexpected result"}

    return {"ok": True, "result": result.model_dump(mode="json")}

Why Validate Results Too?

The result model acts as an allowlist. Pydantic ignores unknown keys by default when it validates a dictionary into a model, so internal fields like database IDs, cost data or notes never reach the model unless you declare them. That protects privacy and keeps the context window small.

It also catches bugs, because a tool that returns the wrong shape fails loudly in your code, not silently in the model's reasoning.

Step 6: Let the Model Repair Its Own Mistakes (Carefully)

When arguments fail validation, you can send the compact error back and let the model try again. Libraries like Instructor automate this: when validation fails, they retry with the error message appended [4][5]. A PyPI package built on Instructor adds a retry decorator that feeds validation errors back to the model for self-correction [5].

If you hand-roll it, keep these guardrails:

  1. Cap retries at two or three.
  2. Never run side effects before validation passes.
  3. Send only compact errors, not raw input.
  4. Use idempotency keys for tools with side effects, so a retried call cannot act twice.
  5. Know the limit. One guide warns that validation loops converge on plausible-but-wrong outputs when the task is underspecified, so retries can polish garbage into shinier garbage [4]. Validation proves a call is well-formed. It does not prove the call is right.

Step 7: Don't Confuse Validation With Authorization

A perfectly valid refund request can still be a request the caller has no right to make. Validate shape first, then authorize:

  • Check the caller's permissions for this tool and this resource.
  • Apply rate and amount limits that depend on the user, not only the schema.
  • Require confirmation for risky actions.
  • Sanitise strings before they reach SQL, shells, templates or URLs. Validators help (patterns, allowlists, length caps), but your downstream code should still use parameterised queries and safe APIs.

My guide to securing MCP servers covers permissions and least privilege in more depth.

Hardening Checklist for Argument Models

  • Forbid extra fields on input models.
  • Use Literal and enums for closed sets.
  • Bound everything: string length, list length, numeric ranges and nesting depth.
  • Reject NaN and infinity in numeric fields.
  • Use patterns for identifiers and allowlists for hosts or domains.
  • Prefer integers for money and timezone-aware datetimes for time.
  • Use discriminated unions when one tool accepts several distinct shapes.
  • Set sensible defaults, and make required fields truly required.

Testing Your Contracts

You can test validation without any API call. One tutorial recommends fixtures: a valid payload that should pass, and invalid ones that should raise the expected error types [2]. The same tutorial reports running its checks with Pydantic 2.12.5 [2], which shows the version you should pin and note in your own tests.

Build a test set from real failures:

import pytest

BAD_CALLS = [
    ('{"order_id": "123", "amount_cents": 100, "reason": "late"}', "string_pattern_mismatch"),
    ('{"order_id": "ORD-123456", "amount_cents": -5, "reason": "late"}', "greater_than"),
    ('{"order_id": "ORD-123456", "amount_cents": 100, "reason": "late", "override": true}', "extra_forbidden"),
]


@pytest.mark.parametrize("raw,error_type", BAD_CALLS)
def test_bad_calls_are_rejected(raw, error_type):
    with pytest.raises(ValidationError) as info:
        RefundArgs.model_validate_json(raw)
    assert error_type in {e["type"] for e in info.value.errors()}

Every malformed call you see in production becomes a new test case. That habit pairs naturally with a regression suite for your prompts and models.

Libraries That Build on Pydantic

  • Instructor: wraps provider SDKs, validates responses against Pydantic models, and retries on failure [5]. Its README says to use it for fast extraction and to reach for PydanticAI when you need agents [5].
  • PydanticAI: the agent runtime from the Pydantic team, described as adding typed tools, evals and observability while using the same Pydantic models [5].
  • Plain Pydantic: often enough for a handful of tools, and it keeps dependencies small.

Check each library's current docs, since APIs move quickly.

Common Pitfalls

  1. Calling json.loads and trusting the dictionary. Validate into a model instead [1].
  2. Executing before validating. Side effects must come last [2].
  3. Allowing extra fields. Surprise keys often signal confusion or manipulation.
  4. Leaving lax coercion on for critical fields, so sloppy output slips through.
  5. Returning raw tool results to the model instead of a result allowlist.
  6. Echoing raw input in error messages, which leaks data into the context.
  7. Retrying without limits. Cap attempts, and make side effects idempotent.
  8. Treating validity as correctness. A well-formed wrong answer is still wrong.

Your Quick-Start Checklist

  1. Write one Pydantic model per tool, with extra="forbid".
  2. Generate the tool's JSON Schema from that model.
  3. Validate raw arguments with model_validate_json.
  4. Add business rules with validators and context.
  5. Return compact error summaries, and log the details.
  6. Validate tool results with an allowlist model.
  7. Run validation before any side effect, then authorize.
  8. Cap retries, and use idempotency keys.
  9. Test with valid and invalid fixtures, and add every production failure.

One Subscription Instead of Five AI Tools

Partner offer

GPTAstra Max: one subscription instead of five AI tools

Chat model, image model and video tool behind one interface with a lifetime licence — one bill while you build and test tool-calling loops.

Get GPTAstra Max

Affiliate link — we may earn a commission at no extra cost to you.

Wrapping Up

A language model proposes, and your code decides. Pydantic gives you a compact way to make that decision: one model per tool describes the contract, validates the arguments, generates the schema and filters the results.

Start with your riskiest tool, forbid extra fields, bound every value, and move validation ahead of every side effect. A dozen lines of contract will catch more bad calls than any prompt instruction ever will.

If the tool calls arrive over MCP rather than a provider SDK, the client walkthrough shows where this checkpoint belongs in the loop, and the Inspector debugging guide shows how to see the raw arguments before you trust them.

Frequently Asked Questions

Why validate tool call arguments with Pydantic?

Because the moment a model's output stops being text and starts being an action, something has to stand between them. A schema check catches a missing field, a wrong type, an out-of-range amount and an unexpected key before any side effect happens. Provider-side structured outputs help, but they cannot enforce business rules like a refund not exceeding the order total, and strict modes are not available on every model.

Should I validate tool results as well as arguments?

Yes. A result model acts as an allowlist: Pydantic drops keys you did not declare, so internal fields such as database IDs, cost data or notes never reach the model. It also catches bugs, because a tool returning the wrong shape fails loudly in your code instead of quietly confusing the model's reasoning. Validating results is also what stops sensitive columns from leaking into the context window.

What is the difference between JSON mode and structured outputs?

JSON mode guarantees syntactically valid JSON, while structured outputs enforce adherence to a supplied schema. Both stop malformed payloads at the provider, and both stop short of your business rules. You can still receive prose around the JSON, Markdown fences around the payload, or a valid call whose arguments do not match your own model. Use provider strictness when it is available, then validate in your code anyway.

Should I use Pydantic strict mode for tool arguments?

Decide per field and per tool. Lax mode coerces compatible values, so the string "5" becomes the integer 5, which is friendly and can hide a model mistake. Strict mode rejects those conversions, which is what you want for money, identifiers and anything where sloppy output should be caught. Use lax where harmless coercion simply avoids a pointless retry, and test the choice against real model output, since behaviour can differ between Pydantic versions.

How do I let the model fix a validation error?

Send back a compact summary of the failure, with the field, the problem and the error type, and let the model retry once or twice. Never include the raw input in that message, because it puts data back into the model's context. Cap the attempts, make side effects idempotent with idempotency keys, and accept the limit: validation proves a call is well formed, not that it is right.

Does validation replace authorization?

No. A perfectly valid refund request can still be one the caller has no right to make. Validate the shape first, then check permissions for that tool and that resource, apply per-user rate and amount limits, require confirmation for risky actions, and sanitise strings before they reach SQL, shells, templates or URLs. Validators reduce the attack surface; parameterised queries and safe APIs are what actually block injection.

Sources

  • [1] Pydantic, "How to Use Pydantic for LLMs: Schema, Validation & Prompts" — validating tool arguments with model_validate_json, schema generation, fenced or chatty JSON: pydantic.dev/articles/llm-intro.md
  • [2] BetterToken.ai, "Structured JSON from an LLM: validate it with Pydantic" on DEV Community — untrusted text, never act before validation, fixtures, tested with Pydantic 2.12.5: cited by description; the URL I had returned 404 when checked, so no link is given.
  • [3] Glukhov, "LLM Structured Output Validation in Python That Holds Up" — JSON mode versus structured outputs, model_json_schema(), extra="forbid": glukhov.org
  • [4] Coding Shuttle, "Structured Outputs: Getting JSON Reliably from LLMs" — the reliability ladder, retry loops and their limits: codingshuttle.com
  • [5] Instructor and its ecosystem — validation, retries and the PydanticAI pointer: github.com/567-labs/instructor; plus the validation-loop package that adds a retry decorator on top of it: pypi.org/project/validation-loop

What are You Looking For?

esc