LLM Structured Output Validation: Why Schema-Valid JSON Can Still Be Wrong (2026)
Your LLM returns schema-valid JSON — but the values are wrong. Learn the Inspection Layer pattern: how to validate structured output beyond JSON Schema with Pydantic, Zod, and custom business logic checks.
Have broken JSON right now? Fix it free in under 1 second — no signup.
Fix My JSON →LLM Structured Output Validation: Why Schema-Valid JSON Can Still Be Wrong
Structured output mode is now standard across OpenAI, Anthropic, and Google Gemini. You define a schema, the model generates JSON guaranteed to match it. Problem solved?
Not quite.
Schema compliance is not semantic correctness. Your LLM can return perfectly valid JSON — every field present, every type correct, every required key populated — and still give you nonsense values. This is the gap the Inspection Layer fills.
The Schema-Valid-But-Wrong Problem
Consider a flight booking API that asks an LLM to extract trip details:
{
"departure": "2026-12-01",
"return": "2026-11-28",
"passengers": -2,
"class": "economy",
"price_usd": 99999999
}
This is perfectly valid JSON. It matches the schema. The date fields are strings, passengers is an integer, class is a string, price_usd is a number. Every field is present.
It is also complete garbage. The return date is before the departure date. Passenger count is negative. The price is suspiciously large.
JSON Schema caught none of this. That's not what JSON Schema is for.
What Providers Actually Guarantee
When OpenAI, Anthropic, or Google say "structured output," they guarantee:
- Structural compliance: The response matches your schema shape
- Type correctness: Fields have the declared types
- Required fields: No missing keys (with
additionalProperties: false)
They do not guarantee:
- Values are in a valid range
- Dates are chronologically sane
- Enum values make sense in context
- Numbers are non-negative where they should be
- Cross-field relationships hold (departure before return)
- Business rules are satisfied
This is not a flaw in the providers' implementation. It's a fundamental limitation of constrained decoding. The model can be forced to output a date string — it cannot be forced to output a correct date.
The Inspection Layer Pattern
The solution is a dedicated validation layer that runs after schema validation and before your application logic acts on the data.
LLM → [Schema Validation] → [Inspection Layer] → Application Logic
↓ ↓
Structural check Semantic check
(JSON Schema) (Business rules)
The inspection layer catches what JSON Schema can't: value ranges, cross-field logic, domain constraints, and plausibility checks.
Python Implementation with Pydantic
Pydantic v2 is the standard choice for the Inspection Layer in Python. Its model_validator and field_validator decorators let you express business rules directly on the model:
from pydantic import BaseModel, field_validator, model_validator
from datetime import date
from typing import Literal
class FlightBooking(BaseModel):
departure: date
return_date: date
passengers: int
travel_class: Literal["economy", "business", "first"]
price_usd: float
@field_validator("passengers")
@classmethod
def passengers_must_be_positive(cls, v: int) -> int:
if v < 1 or v > 9:
raise ValueError(f"Passengers must be 1-9, got {v}")
return v
@field_validator("price_usd")
@classmethod
def price_must_be_plausible(cls, v: float) -> float:
if v <= 0 or v > 50000:
raise ValueError(f"Price ${v} is outside plausible range $1-$50,000")
return v
@model_validator(mode="after")
def return_after_departure(self) -> "FlightBooking":
if self.return_date <= self.departure:
raise ValueError(
f"Return date {self.return_date} must be after departure {self.departure}"
)
return self
Now when you receive LLM output, validate it through the Pydantic model:
import json
from openai import OpenAI
client = OpenAI()
def extract_flight_details(user_query: str) -> FlightBooking:
response = client.chat.completions.create(
model="gpt-4o",
response_format={
"type": "json_schema",
"json_schema": {
"name": "flight_booking",
"strict": True,
"schema": FlightBooking.model_json_schema()
}
},
messages=[
{"role": "system", "content": "Extract flight booking details as JSON."},
{"role": "user", "content": user_query}
]
)
raw = json.loads(response.choices[0].message.content)
try:
return FlightBooking.model_validate(raw)
except ValueError as e:
# Handle validation failure — retry, fallback, or escalate
raise ValueError(f"LLM returned schema-valid but logically invalid data: {e}")
TypeScript Implementation with Zod
For TypeScript projects, Zod provides the same capability:
import { z } from "zod";
import Anthropic from "@anthropic-ai/sdk";
const FlightBookingSchema = z.object({
departure: z.string().date(),
return_date: z.string().date(),
passengers: z.number().int().min(1).max(9),
travel_class: z.enum(["economy", "business", "first"]),
price_usd: z.number().positive().max(50000),
}).refine(
(data) => new Date(data.return_date) > new Date(data.departure),
{
message: "Return date must be after departure",
path: ["return_date"],
}
);
type FlightBooking = z.infer<typeof FlightBookingSchema>;
async function extractFlightDetails(query: string): Promise<FlightBooking> {
const client = new Anthropic();
const response = await client.messages.create({
model: "claude-sonnet-4-6",
max_tokens: 1024,
tools: [
{
name: "extract_flight",
description: "Extract flight booking details",
input_schema: {
type: "object",
properties: {
departure: { type: "string", format: "date" },
return_date: { type: "string", format: "date" },
passengers: { type: "integer", minimum: 1, maximum: 9 },
travel_class: { type: "string", enum: ["economy", "business", "first"] },
price_usd: { type: "number" },
},
required: ["departure", "return_date", "passengers", "travel_class", "price_usd"],
},
},
],
tool_choice: { type: "tool", name: "extract_flight" },
messages: [{ role: "user", content: query }],
});
const toolUse = response.content.find((c) => c.type === "tool_use");
if (!toolUse || toolUse.type !== "tool_use") {
throw new Error("No tool call in response");
}
const parsed = FlightBookingSchema.safeParse(toolUse.input);
if (!parsed.success) {
throw new Error(`Semantic validation failed: ${parsed.error.message}`);
}
return parsed.data;
}
Common Inspection Layer Rules by Domain
Different domains need different business-rule validators. Here are the most common patterns:
Date and Time
# Date ranges must be chronologically valid
if end_date < start_date:
raise ValueError("End must be after start")
# Dates must be in the future for bookings
if booking_date < date.today():
raise ValueError("Booking date cannot be in the past")
# Durations must be within limits
duration = (end_date - start_date).days
if duration > 365:
raise ValueError(f"Trip duration {duration}d exceeds 365-day limit")
Financial Values
# Amounts must be positive
if amount <= 0:
raise ValueError("Amount must be positive")
# Currency codes must be real ISO 4217 codes
VALID_CURRENCIES = {"USD", "EUR", "GBP", "JPY", "CAD", "AUD"}
if currency not in VALID_CURRENCIES:
raise ValueError(f"Unknown currency: {currency}")
# Totals must match line items
if abs(sum(item.price for item in items) - total) > 0.01:
raise ValueError("Line items don't sum to total")
Enumerations in Context
# Enum value must match context
if country == "US" and phone_format != "us":
raise ValueError("US phone numbers must use US format")
# Mutually exclusive fields
if has_primary_address and has_po_box:
raise ValueError("Cannot have both street address and PO box")
Handling Validation Failures: The Retry Pattern
When the Inspection Layer catches a failure, you have three options:
1. Retry with the error as feedback
def extract_with_retry(query: str, max_retries: int = 2) -> FlightBooking:
last_error = None
for attempt in range(max_retries + 1):
try:
result = extract_flight_details(query)
return result
except ValueError as e:
last_error = e
if attempt < max_retries:
query = f"{query}\n\nPrevious attempt failed: {e}. Please correct and try again."
raise ValueError(f"Failed after {max_retries + 1} attempts: {last_error}")
2. Repair with AI JSON Medic
If the JSON structure is valid but values are wrong, sometimes the repair layer can fix plausible data issues. For date transpositions, number sign errors, and similar mechanical mistakes, a repair pass can catch what the LLM got backwards.
Visit AI JSON Medic and paste the schema-valid-but-wrong JSON — the 16-stage Claude repair pipeline often identifies value errors that don't match the surrounding context.
3. Escalate to human review
For critical domains (medical, financial, legal), validation failures should trigger a human review queue rather than silent retries.
def extract_with_escalation(query: str) -> FlightBooking | None:
try:
return extract_flight_details(query)
except ValueError as e:
log_to_review_queue(query=query, error=str(e))
send_alert(f"LLM extraction failed inspection: {e}")
return None
The Inspection Layer vs JSON Schema: What Each Does
| Check | JSON Schema | Inspection Layer |
|---|---|---|
| Field present | ✅ | ✅ |
| Type correct | ✅ | ✅ |
| String format (date, email) | Partial | ✅ |
| Value range (min, max) | Partial | ✅ |
| Cross-field logic | ❌ | ✅ |
| Business rules | ❌ | ✅ |
| Semantic plausibility | ❌ | ✅ |
| Context-dependent validation | ❌ | ✅ |
Use JSON Schema (or provider structured output) to enforce shape. Use the Inspection Layer to enforce meaning.
Why This Happens: The Root Cause
Constrained decoding works by restricting which tokens are valid at each position. For a date field, the model can only output characters that form a valid ISO 8601 date. But the content of that date — whether it makes sense given the other fields — is not constrained.
The model is generating token-by-token. By the time it outputs the return date, it may have "forgotten" the departure date it generated earlier (especially in long contexts). The constraint ensures format; it cannot ensure coherence.
This is why always validate with Pydantic or Zod, even with structured output has become a production engineering best practice in 2026.
Setting Up the Full Pipeline
Here is the complete recommended pipeline for production LLM structured output:
1. Define your schema (Pydantic model or Zod schema)
2. Generate JSON schema from the model for the provider
3. Call LLM with structured output enabled
4. Parse JSON (should not fail — provider guarantees structure)
5. Run Inspection Layer validators (Pydantic model_validate / Zod parse)
6. On failure: retry with error feedback (max 2 retries)
7. On repeated failure: escalate or use fallback
8. On success: pass to application logic
Each step has a clear responsibility. The provider owns structural validity. Your Inspection Layer owns semantic correctness. Your application logic never sees invalid data.
Frequently Asked Questions
Does enablingstrict: true in OpenAI fix semantic validation?
No. strict: true enforces structural compliance more strictly — no extra fields, all required fields present. It does not add any semantic or business-rule validation.
Instructor uses Pydantic internally. When you define your Pydantic models with validators, Instructor automatically uses them. You get the Inspection Layer "for free" if you define validators on your Pydantic classes.
What happens when Pydantic validation fails mid-pipeline?Pydantic raises ValidationError with detailed field-level error messages. You can serialize these errors back to the LLM in a retry prompt: "Field 'return_date' failed: must be after departure." This feedback dramatically improves retry success rates.
Yes. The Inspection Layer is independent of the LLM provider. You validate the output regardless of whether it came from a cloud provider or a local model. Local models often have weaker structural compliance, making the Inspection Layer even more important.
How many validation rules should I add?Add rules for every constraint your application depends on. If a bug from a wrong value would be silent (no exception, just wrong behavior), add a validator for it. The cost of an extra validator is microseconds; the cost of acting on wrong data can be significant.
Can I validate JSON at aijsonmedic.com?Yes — AI JSON Medic's repair tool handles malformed JSON that fails to parse at all. For schema-valid-but-wrong JSON, you need Pydantic or Zod validators as described above. Use the repair tool for structural failures; use the Inspection Layer for semantic failures.
Still dealing with broken JSON?
Paste it in and get it fixed in under 1 second — free, no signup, no install. Works with ChatGPT, Claude, n8n, and any AI output.
Fix My JSON Free →Related Articles