🎉 DevOps Interview Prep Bundle is live — 1000+ Q&A across 20 topicsGet it →
All Articles

Getting Reliable JSON Output from LLMs in Production with Claude API

How to reliably get structured JSON output from Claude API in production — using tool use, Pydantic validation, retry logic, and schema design patterns that prevent the most common failures.

Shubham7 min read
Share:Tweet

Getting an LLM to return JSON sounds trivial until you're running it in production and a malformed response breaks your entire pipeline at 2 AM.

The naive approach — "just ask it to return JSON" — works maybe 90% of the time. That 10% failure rate is completely unacceptable in production systems. Here's how to get to 99.9%+ reliability.

Why "Just Ask for JSON" Fails

When you prompt an LLM to "respond in JSON format," a few things can go wrong:

  • The model adds markdown code fences: ```json\n{...}\n```
  • The model adds explanatory text before or after the JSON
  • The model generates valid JSON but with wrong field names or types
  • The model generates truncated JSON if the response is long
  • The model uses single quotes instead of double quotes

Each of these breaks json.loads(). You end up writing increasingly complex regex to strip the fences, which breaks when the model changes behavior slightly in a new version.

There's a better way.

Method 1: Tool Use (Best for Production)

The most reliable way to get structured output from Claude is to define the structure as a tool — even if you never actually "call" the tool. The model will generate perfectly valid JSON that matches your schema because it's constrained to do so.

python
import anthropic
from typing import Any
 
client = anthropic.Anthropic()
 
 
def extract_deployment_info(description: str) -> dict[str, Any]:
    """
    Extract structured deployment information from natural language.
    Uses tool use to guarantee valid JSON output.
    """
    tools = [
        {
            "name": "record_deployment_info",
            "description": "Record structured information about a deployment",
            "input_schema": {
                "type": "object",
                "properties": {
                    "service_name": {
                        "type": "string",
                        "description": "Name of the service being deployed"
                    },
                    "environment": {
                        "type": "string",
                        "enum": ["development", "staging", "production"],
                        "description": "Target deployment environment"
                    },
                    "image_tag": {
                        "type": "string",
                        "description": "Container image tag or version"
                    },
                    "risk_level": {
                        "type": "string",
                        "enum": ["low", "medium", "high", "critical"],
                        "description": "Assessed risk level for this deployment"
                    },
                    "requires_migration": {
                        "type": "boolean",
                        "description": "Whether this deployment requires a database migration"
                    },
                    "rollback_plan": {
                        "type": "string",
                        "description": "Brief description of rollback procedure"
                    }
                },
                "required": ["service_name", "environment", "risk_level", "requires_migration"]
            }
        }
    ]
 
    response = client.messages.create(
        model="claude-sonnet-5",
        max_tokens=1024,
        tools=tools,
        tool_choice={"type": "any"},  # Force tool use
        messages=[
            {
                "role": "user",
                "content": f"Extract deployment information from this description: {description}"
            }
        ]
    )
 
    # Extract the tool call result
    for block in response.content:
        if block.type == "tool_use":
            return block.input  # Already a valid Python dict
 
    raise ValueError("Model did not use the tool as expected")
 
 
# Example usage
result = extract_deployment_info(
    "We need to deploy payment-service v2.3.1 to production tonight. "
    "It has a schema migration for the transactions table. "
    "Previous deployment had issues so we should be careful."
)
 
print(result)
# {
#   "service_name": "payment-service",
#   "environment": "production",
#   "image_tag": "v2.3.1",
#   "risk_level": "high",
#   "requires_migration": True,
#   "rollback_plan": "Revert to v2.2.9 and run down migration"
# }

The key here: tool_choice={"type": "any"} forces Claude to use one of the provided tools. The JSON schema in the tool definition constrains exactly what fields are returned and their types. You'll never get markdown fences or explanatory text.

Method 2: Pydantic + Tool Use (Best for Type Safety)

Combine tool use with Pydantic for full type validation in Python:

python
from pydantic import BaseModel, Field, field_validator
from typing import Optional
import anthropic
import json
 
 
class DeploymentAnalysis(BaseModel):
    service_name: str
    environment: str
    risk_level: str
    requires_migration: bool
    estimated_downtime_seconds: Optional[int] = None
    rollback_plan: str
    concerns: list[str] = Field(default_factory=list)
 
    @field_validator("environment")
    @classmethod
    def validate_environment(cls, v: str) -> str:
        valid = {"development", "staging", "production"}
        if v not in valid:
            raise ValueError(f"Must be one of: {valid}")
        return v
 
    @field_validator("risk_level")
    @classmethod
    def validate_risk(cls, v: str) -> str:
        valid = {"low", "medium", "high", "critical"}
        if v not in valid:
            raise ValueError(f"Must be one of: {valid}")
        return v
 
 
def analyze_deployment(description: str) -> DeploymentAnalysis:
    """Analyze a deployment description and return validated structured data."""
    client = anthropic.Anthropic()
 
    # Generate schema from Pydantic model
    schema = DeploymentAnalysis.model_json_schema()
 
    response = client.messages.create(
        model="claude-sonnet-5",
        max_tokens=1024,
        tools=[{
            "name": "analyze_deployment",
            "description": "Analyze a deployment and record structured assessment",
            "input_schema": schema
        }],
        tool_choice={"type": "any"},
        messages=[{"role": "user", "content": description}]
    )
 
    for block in response.content:
        if block.type == "tool_use":
            # Pydantic validates and coerces the data
            return DeploymentAnalysis(**block.input)
 
    raise ValueError("No tool use in response")

This approach gives you:

  • Guaranteed JSON from Claude (via tool use)
  • Type validation and coercion from Pydantic
  • Clear error messages when data doesn't match the schema
  • IDE autocomplete on the returned object

Method 3: Retry with Validation

For cases where you can't use tool use (some fine-tuned models, specific use cases), add retry logic with explicit validation:

python
import json
import re
from typing import TypeVar, Type
from pydantic import BaseModel, ValidationError
 
T = TypeVar("T", bound=BaseModel)
 
 
def extract_json_from_text(text: str) -> str:
    """Strip markdown code fences and extract JSON."""
    # Try to find JSON in code fences
    fence_match = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
    if fence_match:
        return fence_match.group(1)
 
    # Try to find raw JSON object
    json_match = re.search(r"\{.*\}", text, re.DOTALL)
    if json_match:
        return json_match.group(0)
 
    return text.strip()
 
 
def llm_structured_output(
    prompt: str,
    schema_class: Type[T],
    max_retries: int = 3
) -> T:
    """
    Get structured output from Claude with automatic retry and validation.
    Falls back to repair prompting on validation failure.
    """
    client = anthropic.Anthropic()
 
    schema_str = json.dumps(schema_class.model_json_schema(), indent=2)
 
    messages = [
        {
            "role": "user",
            "content": f"""{prompt}
 
Respond ONLY with a valid JSON object matching this schema. No markdown, no explanation:
{schema_str}"""
        }
    ]
 
    for attempt in range(max_retries):
        response = client.messages.create(
            model="claude-sonnet-5",
            max_tokens=1024,
            messages=messages
        )
 
        raw_text = response.content[0].text
 
        try:
            json_str = extract_json_from_text(raw_text)
            data = json.loads(json_str)
            return schema_class(**data)
 
        except (json.JSONDecodeError, ValidationError) as e:
            if attempt == max_retries - 1:
                raise
 
            # Ask Claude to fix its own output
            messages.append({"role": "assistant", "content": raw_text})
            messages.append({
                "role": "user",
                "content": f"That response had an error: {str(e)}. "
                          f"Please return ONLY valid JSON matching the schema."
            })
 
    raise ValueError("Failed to get valid structured output after retries")

Schema Design Patterns That Prevent Failures

How you design your JSON schema matters as much as how you extract the output.

Use enums for constrained values. Instead of asking for "risk level as low, medium, high, or critical," define it as an enum in your schema. The model won't invent new values.

Keep nesting shallow. Deeply nested JSON is more likely to be malformed. If you need nested data, consider flattening it with prefixed keys (deployment_service_name instead of deployment.service.name).

Use arrays of simple objects. Arrays of complex nested objects fail more often than arrays of strings or flat objects.

Separate concerns. Don't ask for both analysis and action items in one schema if they're optional. Make one required call for the core data and a second optional call for recommendations.

Always mark critical fields as required. Optional fields can be omitted, but required fields force the model to include them or fail early.

Monitoring Output Quality

In production, log validation failures:

python
import logging
 
logger = logging.getLogger(__name__)
 
 
def safe_structured_output(prompt: str, schema_class: Type[T]) -> T | None:
    """Wrapper with error logging for production use."""
    try:
        result = llm_structured_output(prompt, schema_class)
        logger.info({
            "event": "structured_output_success",
            "schema": schema_class.__name__
        })
        return result
    except Exception as e:
        logger.error({
            "event": "structured_output_failure",
            "schema": schema_class.__name__,
            "error": str(e)
        })
        return None

Track your failure rate. If it climbs above 1%, your prompt or schema needs work.

The Hierarchy of Reliability

From most to least reliable:

  1. Tool use with tool_choice: any — near 100% valid JSON, schema-constrained
  2. Tool use without forcing — Claude sometimes adds text before the tool call
  3. System prompt with schema — good but not guaranteed
  4. User prompt with JSON instruction — works 90% of the time
  5. "Respond in JSON" — least reliable, avoid in production

For anything in production: use tool use. The extra setup is worth it. A single production incident caused by a malformed JSON response costs more time than the implementation ever would.


Building LLM-powered DevOps tools? Check out our LLM context window management guide and RAG in production guide.

🔧

Today I Fixed

Short real fixes from production — posted daily

Browse fixes
Newsletter

Stay ahead of the curve

Get the latest DevOps, Kubernetes, AWS, and AI/ML guides delivered straight to your inbox. No spam — just practical engineering content.

Related Articles

Comments