Handle Invalid JSON

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

When Output Can Still Be Invalid

Structured outputs remove most JSON problems, but not all. According to the documentation, output might not match your schema when a response is a refusal or is cut off at max_tokens. Your own constraints and validators can also reject well-formed output (Lessons 103 and 104). And code paths without structured outputs, such as older prompt-based JSON, can still produce malformed text.

ProblemSignalRight response
Refusalstop_reason == "refusal"Do not retry the same request; report or fall back
Truncated outputstop_reason == "max_tokens"Retry the same request with a higher max_tokens
Constraint or validator failureValidation error after parsingRetry with feedback describing the problems
Enum capitalizationValue differs only in caseNormalize; no retry needed (Lesson 103)
Schema too complex400 error: "Schema is too complex for compilation"Simplify the schema; retrying cannot help (Lesson 108)
Malformed JSON from prompt-based requestsjson.JSONDecodeErrorRepair if possible (Lesson 36), or switch to structured outputs

The key idea: diagnose first, then choose the recovery. Retrying blindly wastes tokens, and some failures can never be fixed by retrying.


Strategy 1: Truncated Output

A truncated response is incomplete JSON. Retrying with more room usually solves it. Retry the same messages, without adding the truncated response to the conversation, and double max_tokens, up to a ceiling:

if response.stop_reason == "max_tokens":
    max_tokens = min(max_tokens * 2, MAX_TOKENS_CEILING)

Remember that thinking also uses max_tokens (Lesson 13). If retries keep truncating, the requested output is probably too large: ask for less, or split the task into smaller requests.


Strategy 2: Retry with Feedback

When the JSON is complete but fails your constraints or validators, Claude can usually fix it, if it knows what was wrong. Continue the conversation with:

  1. Claude's previous output, as an assistant turn, and
  2. a user turn that lists the specific problems and asks for a corrected version.
messages = messages + [
    {"role": "assistant", "content": previous_text},
    {"role": "user", "content": f"That output has problems: {problems}. Return a corrected version."},
]

This is a normal multi-turn conversation that ends with a user message, so it works on current models, which do not support prefill (Lesson 18). The structured output format still applies to the corrected response.

Getting the Text You Need for Feedback

messages.parse() validates the response for you, but when validation fails, you need the raw text to send it back. A flexible pattern is to request the structured output with messages.create(), sending the schema generated from your model with transform_schema() (Lesson 102), and validate the text yourself with model_validate_json():

response = client.messages.create(
    model=MODEL, max_tokens=max_tokens, messages=messages,
    output_config={"format": {"type": "json_schema", "schema": transform_schema(OutputModel)}},
)
text = next(block.text for block in response.content if block.type == "text")
result = OutputModel.model_validate_json(text)      # Raises ValidationError with details

model_validate_json() parses and validates in one step, and runs all constraints and validators, so you get the same checks as messages.parse(), plus full access to the text.


Strategy 3: Refusals

A refusal is a deliberate safety decision, not a formatting problem. Retrying the same request will usually produce the same result. Return a clear "could not process" outcome, log it, and, if your application allows, offer the user an alternative (Lesson 70).


Strategy 4: Repair for Prompt-Based JSON

Without structured outputs (for example, on a platform or in legacy code that requests JSON through the prompt), malformed responses are more likely: code fences around the JSON, explanatory text, or truncation. Lesson 36's extract_json() handles fences and surrounding text. Beyond that, do not attempt clever repairs of broken syntax; they can silently change data. Switch to structured outputs where possible.


Always Bound the Recovery

Every strategy needs limits:

  • a maximum number of attempts (for example, 3),
  • a ceiling for max_tokens, and
  • a clear failure result when recovery does not succeed, so calling code can handle it.

Practical Example: Structured Output with Recovery

This module wraps structured-output requests with all three recovery strategies: more room for truncated output, feedback for validation failures, and a clean stop for refusals. It generates quiz questions with a validator that checks the answer is one of the options.

"""recover.py: structured output with bounded recovery from invalid results."""

from dataclasses import dataclass
from typing import Generic, TypeVar

from anthropic import Anthropic, transform_schema
from pydantic import BaseModel, Field, ValidationError, model_validator

client = Anthropic()
MODEL = "claude-opus-5-5"
T = TypeVar("T", bound=BaseModel)


@dataclass
class Outcome(Generic[T]):
    value: T | None
    status: str          # "ok", "refused", "truncated", or "invalid"
    attempts: int
    detail: str = ""


def describe_errors(error: ValidationError) -> str:
    return "; ".join(f"{'.'.join(str(p) for p in e['loc']) or 'output'}: {e['msg']}" for e in error.errors())


def parse_with_recovery(output_type: type[T], prompt: str, *, max_tokens: int = 1024,
                        ceiling: int = 8192, max_attempts: int = 3) -> Outcome[T]:
    schema = transform_schema(output_type)
    messages = [{"role": "user", "content": prompt}]
    detail = ""

    for attempt in range(1, max_attempts + 1):
        response = client.messages.create(
            model=MODEL, max_tokens=max_tokens, messages=messages,
            output_config={"format": {"type": "json_schema", "schema": schema}},
        )
        if response.stop_reason == "refusal":
            return Outcome(None, "refused", attempt, "Claude declined this request.")
        if response.stop_reason == "max_tokens":
            if max_tokens >= ceiling:
                return Outcome(None, "truncated", attempt, f"Output did not fit in {ceiling} tokens.")
            max_tokens = min(max_tokens * 2, ceiling)
            detail = f"truncated; retrying with max_tokens={max_tokens}"
            continue

        text = next(block.text for block in response.content if block.type == "text")
        try:
            return Outcome(output_type.model_validate_json(text), "ok", attempt, detail)
        except ValidationError as error:
            detail = describe_errors(error)
            messages = messages + [
                {"role": "assistant", "content": text},
                {"role": "user", "content": f"That output has problems: {detail}. "
                                            "Return a corrected version that fixes all of them."},
            ]

    return Outcome(None, "invalid", max_attempts, detail)


class QuizQuestion(BaseModel):
    question: str
    options: list[str] = Field(min_length=4, max_length=4, description="Exactly 4 answer options")
    answer: str = Field(description="The correct option, copied exactly from options")
    explanation: str = Field(description="One sentence explaining why the answer is correct")

    @model_validator(mode="after")
    def answer_is_an_option(self) -> "QuizQuestion":
        if self.answer not in self.options:
            raise ValueError("answer must be exactly one of the options")
        if len(set(self.options)) != len(self.options):
            raise ValueError("options must be different from each other")
        return self


if __name__ == "__main__":
    outcome = parse_with_recovery(
        QuizQuestion,
        "Write one multiple-choice question about Python list slicing for beginners.",
    )
    print(f"status={outcome.status} attempts={outcome.attempts} detail={outcome.detail!r}")
    if outcome.value:
        q = outcome.value
        print(q.question)
        for option in q.options:
            print(f"  {'*' if option == q.answer else ' '} {option}")
        print(f"  ({q.explanation})")

Example output (questions vary; most runs succeed on the first attempt):

status=ok attempts=1 detail=''
What does my_list[1:3] return if my_list = [10, 20, 30, 40]?
    [10, 20]
  * [20, 30]
    [20, 30, 40]
    [30, 40]
  (Slicing includes the start index 1 and stops before the end index 3.)

How It Works

  1. Outcome is a generic result: the validated value (or None), a status, the number of attempts, and details. Generic[T] lets type checkers know that parse_with_recovery(QuizQuestion, ...) returns an Outcome[QuizQuestion].
  2. transform_schema(output_type) produces the schema to send, once. The removed constraints, such as min_length=4 on options, appear in the description, and are still checked by model_validate_json().
  3. Refusals return immediately with status "refused". Retrying would not help.
  4. Truncation doubles max_tokens and retries the same messages, without adding the cut-off output, until the ceiling is reached.
  5. Validation failures are described field by field with describe_errors(), and sent back to Claude, together with its previous output, as a new user message. The next attempt produces a corrected version.
  6. The attempt limit guarantees that the function ends, with status "invalid" and the last problems if no attempt passed validation.
  7. QuizQuestion's validator checks rules that a schema cannot express: the answer must match one of the options exactly, and the options must be distinct. If Claude wrote an answer that differed slightly from its option text, the feedback loop would ask it to copy the option exactly.

Common Mistakes

Retrying Everything the Same Way

What happens: refusals are retried pointlessly, and truncation is "fixed" by asking the same question again with the same limit.

Fix: choose the recovery from the diagnosis.

Retrying Without Feedback

What happens: the same validation error repeats.

Fix: include the previous output and the specific problems.

Adding Truncated Output to the Conversation

What happens: the conversation contains broken JSON, which can confuse later attempts.

Fix: retry truncated requests with the original messages and a higher limit.

Unlimited Retries

What happens: a persistent problem burns tokens indefinitely.

Fix: limit attempts and max_tokens, and return a clear failure.

Retrying a "Schema Too Complex" Error

What happens: every attempt fails with the same 400 error.

Fix: simplify the schema (Lesson 108).


Best Practices

  • Check stop_reason before parsing, and handle refusals and truncation separately.
  • Retry truncated output with more tokens, up to a ceiling.
  • Retry validation failures with specific feedback.
  • Use model_validate_json() when you need both validation and the raw text.
  • Bound every recovery with attempt and size limits.
  • Return a result object with status and details, and log failures.

Exercise

  1. Call parse_with_recovery() with max_tokens=64 to force truncation. How many attempts does it take, and what max_tokens value finally works?
  2. Add a validator to QuizQuestion that rejects explanations longer than 30 words, and observe whether the feedback loop is needed.
  3. Make parse_with_recovery() log every attempt (status and detail) with the logging module.
  4. Explain why the retry for validation failures adds Claude's previous output as an assistant message rather than including it in the user message.
Show answers for steps 1 and 4

Step 1: with max_tokens=64, the first attempt is usually cut off. The function doubles the limit on each retry: 64, then 128, then 256. Because max_attempts=3, the run either succeeds within those three attempts or returns "truncated"/"invalid". Current models think before answering, and thinking counts toward max_tokens, so very small limits can leave no room for the answer at all. Start from realistic values such as 1,024.

Step 4: as an assistant message, the previous output is clearly Claude's own earlier answer, and the user message that follows reads naturally as feedback on it: "That output has problems: …". This is the standard multi-turn structure, and it keeps the roles clear. Pasting the output into the user message would mix the user's instructions with model output, which is less clear and can be confused with data that the user provided.

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 Handle Invalid JSON and get answers drawn from it.

Signed-in readers only.