← Back to DevBytes

Structured Output Validation with Claude Code: Complete Guide

Introduction to Structured Output Validation

When building applications with large language models like Claude, one of the most common challenges developers face is getting reliable, machine-readable output. Free-form text responses are great for chatbots, but when you need to parse responses programmatically, feed them into databases, or pass them to downstream systems, you need structure. This is where structured output validation comes in.

Structured output validation is the practice of constraining Claude's responses to a predefined schema—typically JSON—and then validating that the response conforms to your expected format before using it in your application. With Claude Code, Anthropic's command-line tool for agentic coding, you can build robust pipelines that generate, validate, and handle structured data with confidence.

Why Structured Output Validation Matters

Without validation, you are trusting the model to always produce perfectly formatted output. In practice, this trust is misplaced for several reasons:

By implementing structured output validation, you create a contract between the model and your application. This contract is enforced at runtime, giving you a safety net that catches problems early and allows for graceful error handling.

How Claude Code Handles Structured Output

Claude Code is a CLI-based agentic tool that can interact with your codebase, run commands, and execute scripts. While Claude Code itself is designed for coding tasks, you can leverage it to build and test structured output pipelines using the Anthropic API. The key mechanism for structured output with Claude is the tool_use feature, which forces the model to produce output conforming to a JSON schema you define.

Tool Use as a Structured Output Mechanism

Instead of asking Claude to "return JSON" in a prompt and hoping for the best, you define a tool with an input schema. When you instruct Claude to use that tool, it is constrained to produce a tool_use block whose input field must match your schema. This is far more reliable than prompt-based JSON instructions because the model's tool-use mechanism is specifically trained to produce schema-conformant output.

Setting Up Your Environment

Before diving into code, make sure you have the prerequisites installed. You will need Python 3.9 or later, the Anthropic Python SDK, and the Pydantic library for schema validation.

pip install anthropic pydantic

Set your API key as an environment variable:

export ANTHROPIC_API_KEY="your-api-key-here"

Basic Example: Defining a Schema and Calling Claude

Let us start with a simple example. Suppose you want Claude to extract structured information about a person from an unstructured text description. You define a Pydantic model, convert it to a JSON schema, and pass it as a tool definition to Claude.

import json
from anthropic import Anthropic
from pydantic import BaseModel, Field

client = Anthropic()

class PersonInfo(BaseModel):
    name: str = Field(description="The person's full name")
    age: int | None = Field(default=None, description="The person's age, if known")
    occupation: str | None = Field(default=None, description="The person's job title")
    skills: list[str] = Field(default_factory=list, description="List of skills mentioned")

# Convert the Pydantic model to a JSON schema for the tool definition
tool_schema = {
    "name": "extract_person_info",
    "description": "Extract structured information about a person from text.",
    "input_schema": PersonInfo.model_json_schema(),
}

text = """
Sarah Chen is a 32-year-old senior software engineer at a fintech startup.
She specializes in Python, distributed systems, and machine learning.
She recently led a team that built a real-time fraud detection pipeline.
"""

response = client.messages.create(
    model="claude-sonnet-4-20250514",
    max_tokens=1024,
    tools=[tool_schema],
    tool_choice={"type": "tool", "name": "extract_person_info"},
    messages=[
        {
            "role": "user",
            "content": f"Extract person information from this text:\n\n{text}",
        }
    ],
)

# Extract the tool input from the response
for block in response.content:
    if block.type == "tool_use":
        raw_data = block.input
        break

# Validate the data against the Pydantic model
person = PersonInfo(**raw_data)
print(person.model_dump_json(indent=2))

In this example, the tool_choice parameter is set to force Claude to use the specific tool, guaranteeing structured output. The Pydantic model serves double duty: it generates the JSON schema for the tool definition and validates the response after extraction.

Handling Validation Errors

Even with tool use, validation can fail. The model might return a string where an integer is expected, or a field might be missing. You need a strategy for handling these cases gracefully.

from pydantic import ValidationError

def extract_with_validation(text: str, max_retries: int = 2) -> PersonInfo:
    messages = [
        {
            "role": "user",
            "content": f"Extract person information from this text:\n\n{text}",
        }
    ]

    for attempt in range(max_retries + 1):
        response = client.messages.create(
            model="claude-sonnet-4-20250514",
            max_tokens=1024,
            tools=[tool_schema],
            tool_choice={"type": "tool", "name": "extract_person_info"},
            messages=messages,
        )

        raw_data = None
        for block in response.content:
            if block.type == "tool_use":
                raw_data = block.input
                break

        if raw_data is None:
            raise RuntimeError("Claude did not return a tool_use block")

        try:
            return PersonInfo(**raw_data)
        except ValidationError as e:
            if attempt == max_retries:
                raise

            # Feed the validation error back to Claude for correction
            error_message = (
                f"The previous output failed validation with these errors:\n\n"
                f"{e}\n\n"
                f"Please correct the output and call the tool again with valid data."
            )

            messages.append({"role": "assistant", "content": response.content})
            messages.append({"role": "user", "content": error_message})

    raise RuntimeError("Max retries exceeded")

This retry loop is a powerful pattern. By feeding the validation error back to Claude, you give the model a chance to self-correct. In practice, this resolves the vast majority of validation failures on the second attempt.

Advanced Schema Patterns

Nested Objects

Real-world schemas are rarely flat. You will often need nested objects, arrays of objects, and optional fields. Pydantic handles all of these naturally, and the generated JSON schema propagates the structure to Claude.

from typing import Literal
from pydantic import BaseModel, Field

class Address(BaseModel):
    street: str
    city: str
    state: str
    zip_code: str = Field(pattern=r"^\d{5}(-\d{4})?$")

class OrderItem(BaseModel):
    product_name: str
    quantity: int = Field(gt=0)
    unit_price: float = Field(ge=0)

class Order(BaseModel):
    order_id: str
    customer_name: str
    shipping_address: Address
    items: list[OrderItem] = Field(min_length=1)
    status: Literal["pending", "shipped", "delivered", "cancelled"]
    notes: str | None = None

order_tool = {
    "name": "create_order",
    "description": "Create a structured order record from unstructured input.",
    "input_schema": Order.model_json_schema(),
}

Notice the use of Field constraints like gt=0, ge=0, min_length=1, and pattern. These constraints are included in the JSON schema and help guide Claude's output, while also serving as validation rules after extraction.

Enums and Literal Types

Using Literal types or Python enums restricts fields to a fixed set of values. This is extremely useful for classification tasks, status fields, and categorical data. The JSON schema represents these as enum properties, which Claude respects well.

from enum import Enum

class Sentiment(str, Enum):
    POSITIVE = "positive"
    NEGATIVE = "negative"
    NEUTRAL = "neutral"
    MIXED = "mixed"

class SentimentAnalysis(BaseModel):
    sentiment: Sentiment
    confidence: float = Field(ge=0.0, le=1.0)
    reasoning: str
    key_phrases: list[str] = Field(default_factory=list)

Using Claude Code to Build and Test Validation Pipelines

Claude Code can accelerate your development workflow by scaffolding validation code, writing test cases, and iterating on schemas. Here is how you can use it effectively:

From your terminal, navigate to your project directory and launch Claude Code:

claude

You can then ask Claude Code to help you build a complete validation module. For example:

> Create a Pydantic schema for extracting meeting notes from a transcript.
  Include fields for date, attendees, action items (with assignee and due date),
  decisions made, and topics discussed. Then write a function that calls the
  Anthropic API with tool_use to extract this data, with retry-on-validation-error
  logic. Include unit tests using pytest.

Claude Code will generate the schema, the extraction function, and the tests, placing them in appropriate files in your project. You can then review, refine, and run the tests:

pytest test_meeting_notes.py -v

Iterative Schema Refinement

One of the most valuable uses of Claude Code is iterative refinement. When you find that Claude's output does not match your expectations in production, you can ask Claude Code to analyze the failures and adjust the schema or prompt:

> Look at the test failures in test_meeting_notes.py. The model is returning
  due dates as relative strings like "next Friday" instead of ISO format dates.
  Update the schema field description and the system prompt to enforce ISO 8601
  date format, then re-run the tests.

Best Practices for Structured Output Validation

1. Always Use Tool Use Over Prompt-Based JSON

Never rely on instructions like "respond in JSON format" alone. Tool use with tool_choice set to force a specific tool is dramatically more reliable. It constrains the model at the generation level rather than relying on the model's compliance with natural language instructions.

2. Write Descriptive Field Descriptions

The description parameter in Pydantic's Field is included in the JSON schema and acts as guidance for Claude. Be specific about format, allowed values, and semantics.

# Poor description
due_date: str

# Good description
due_date: str = Field(
    description="The due date in ISO 8601 format (YYYY-MM-DD). "
                "If no specific date is mentioned, use null."
)

3. Validate at the Boundary

Always validate the model's output before it enters your application logic. Treat the LLM as an untrusted external system, similar to how you would treat user input from a web form. Use Pydantic's validation as your boundary check.

4. Implement Retry Logic with Error Feedback

As shown earlier, feeding validation errors back to the model is highly effective. Implement a retry loop that includes the specific validation error message so Claude can understand what went wrong and fix it.

5. Use Strict Types Over Loose Types

Prefer int and float over str for numeric data. Use Literal or enums for categorical fields. Use datetime types with custom validators for dates. The more specific your types, the more guidance Claude receives and the more robust your validation becomes.

6. Keep Schemas Focused

Avoid creating massive schemas with dozens of fields in a single tool. If you need to extract a lot of information, consider breaking it into multiple tools or multiple calls. Focused schemas produce more accurate results.

7. Log Raw Outputs for Debugging

Always log the raw tool_use input before validation. When validation fails, this log helps you understand what the model actually produced and identify patterns in failures.

import logging

logger = logging.getLogger(__name__)

for block in response.content:
    if block.type == "tool_use":
        raw_data = block.input
        logger.debug("Raw tool output: %s", json.dumps(raw_data, indent=2))
        try:
            return PersonInfo(**raw_data)
        except ValidationError:
            logger.warning("Validation failed for raw data: %s", raw_data)
            raise

8. Test with Edge Cases

Build a test suite that includes edge cases: empty input, ambiguous text, conflicting information, very long inputs, and inputs in different languages. This helps you identify schema weaknesses before they hit production.

Putting It All Together: A Complete Example

Here is a complete, production-ready module that demonstrates all the patterns discussed:

import json
import logging
from typing import Literal
from pydantic import BaseModel, Field, ValidationError
from anthropic import Anthropic

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)

client = Anthropic()

class CodeReview(BaseModel):
    severity: Literal["critical", "warning", "info", "positive"]
    category: Literal["security", "performance", "style", "bug", "architecture"]
    file_path: str = Field(description="Path to the file being reviewed")
    line_range: str = Field(description="Line range as 'start-end', e.g. '12-18'")
    issue: str = Field(description="Clear description of the issue found")
    suggestion: str = Field(description="Concrete code suggestion to fix the issue")

class CodeReviewResult(BaseModel):
    reviews: list[CodeReview] = Field(
        default_factory=list,
        description="List of code review findings"
    )
    summary: str = Field(description="Brief summary of the overall code quality")

review_tool = {
    "name": "submit_code_review",
    "description": "Submit a structured code review with findings and a summary.",
    "input_schema": CodeReviewResult.model_json_schema(),
}

def review_code(code: str, language: str = "python", max_retries: int = 3) -> CodeReviewResult:
    messages = [
        {
            "role": "user",
            "content": (
                f"Review the following {language} code and submit your findings "
                f"using the submit_code_review tool. Be thorough but concise.\n\n"
                f"\n{code}\n"
            ),
        }
    ]

    for attempt in range(max_retries + 1):
        logger.info("Attempt %d/%d", attempt + 1, max_retries + 1)

        response = client.messages.create(
            model="claude-sonnet-4-20250514",
            max_tokens=4096,
            tools=[review_tool],
            tool_choice={"type": "tool", "name": "submit_code_review"},
            messages=messages,
        )

        raw_data = None
        for block in response.content:
            if block.type == "tool_use":
                raw_data = block.input
                break

        if raw_data is None:
            logger.error("No tool_use block in response")
            raise RuntimeError("Claude did not return a tool_use block")

        logger.debug("Raw output: %s", json.dumps(raw_data, indent=2))

        try:
            result = CodeReviewResult(**raw_data)
            logger.info("Validation passed with %d reviews", len(result.reviews))
            return result
        except ValidationError as e:
            logger.warning("Validation failed: %s", e)
            if attempt == max_retries:
                raise

            messages.append({"role": "assistant", "content": response.content})
            messages.append({
                "role": "user",
                "content": (
                    f"The previous output failed validation:\n\n{e}\n\n"
                    f"Please fix the issues and call submit_code_review again."
                ),
            })

    raise RuntimeError("Max retries exceeded")


if __name__ == "__main__":
    sample_code = """
def get_user(id):
    conn = sqlite3.connect("users.db")
    c = conn.cursor()
    c.execute("SELECT * FROM users WHERE id = " + id)
    return c.fetchone()
    """

    result = review_code(sample_code)
    print(json.dumps(result.model_dump(), indent=2))

This example covers the full lifecycle: schema definition with strict types and enums, tool-based structured output, retry logic with error feedback, logging for debugging, and a clean public API. You can drop this into a project and extend it for your specific use case.

Conclusion

Structured output validation is a critical skill for anyone building production applications with Claude. By leveraging tool use instead of prompt-based JSON instructions, defining strict Pydantic schemas with descriptive field annotations, implementing retry loops that feed validation errors back to the model, and following best practices around boundary validation and logging, you can build pipelines that are reliable enough for real-world use. Claude Code further accelerates this workflow by helping you scaffold schemas, write tests, and iterate on refinements directly from your terminal. The combination of Claude's tool-use mechanism and Pydantic's validation creates a robust contract between your LLM calls and your application logic, turning unpredictable text generation into dependable, type-safe data extraction.

— Ad —

Google AdSense will appear here after approval

← Back to all articles