Home / Guides / Validate LLM JSON Against a Schema

Validate LLM JSON Output Against a Schema

You got valid JSON from the LLM. But is it the right JSON? Missing fields, wrong types, bad enum values — schema validation catches what json.loads() can't.

Applies to: Any LLM returning JSON — GPT-4o, Claude, DeepSeek, Llama, Mistral, and others via any API provider

The gap between "valid JSON" and "correct JSON"

json.loads() only checks syntax. It doesn't know your age field should be an integer, or that status must be one of three values. Syntactically valid JSON can still break your downstream code.

❌ Parses fine, but wrong
import json

# This parses without error:
data = json.loads('{"name": "Alice", "age": "twenty-five", "status": "maybe"}')

# But your API expects:
#   age    → integer, not string
#   status → "active" | "inactive", not "maybe"
The schema you actually need to enforce
{
  "type": "object",
  "properties": {
    "name":   {"type": "string"},
    "age":    {"type": "integer", "minimum": 0},
    "status": {"type": "string", "enum": ["active", "inactive"]}
  },
  "required": ["name", "age", "status"]
}
The problem: LLMs return strings. Even when you tell them age should be an integer, they sometimes return "25" (a string) instead of 25 (a number). And enum values? Models love inventing new ones.

Approach 1: Pydantic validation

Define your expected structure as a Pydantic model and let it validate (and coerce) the parsed data. This is the most popular Python approach.

✅ Pydantic v2 validation
from pydantic import BaseModel
from enum import Enum
import json

class Status(str, Enum):
    active = "active"
    inactive = "inactive"

class User(BaseModel):
    name: str
    age: int
    status: Status
    active: bool = True

data = json.loads(llm_output)
user = User(**data)  # ValidationError if age is not coercible to int

# Pydantic v2 coerces "25" → 25 automatically
# But "twenty-five" still raises ValidationError
Pros
  • Type coercion built in (v2)
  • Rich error messages
  • Python ecosystem standard
Cons
  • Python only
  • No auto-retry on failure
  • Doesn't fix syntax errors

Approach 2: jsonschema validation

The jsonschema library validates against a standard JSON Schema document. Language-agnostic schema definitions that work in Python, JavaScript, Go, or anywhere.

✅ jsonschema validation
import jsonschema
import json

schema = {
    "type": "object",
    "properties": {
        "name":   {"type": "string"},
        "age":    {"type": "integer", "minimum": 0},
        "status": {"type": "string", "enum": ["active", "inactive"]}
    },
    "required": ["name", "age", "status"]
}

data = json.loads(llm_output)
jsonschema.validate(data, schema)  # raises ValidationError on mismatch

# ♥ Strict: "25" (string) FAILS the integer check
# ♥ Enum: "maybe" FAILS the enum check
Pros
  • Industry standard schema format
  • Language-agnostic definitions
  • Strict type checking
Cons
  • No type coercion ("25" fails)
  • No auto-retry on failure
  • No syntax repair

Approach 3: Contract Mode (StreamFix)

Instead of validating after the fact, enforce the schema at the proxy layer. StreamFix repairs syntax first (markdown fences, Python literals, trailing commas), then coerces types, then validates against your schema — and auto-retries once if validation fails.

✅ StreamFix Contract Mode
from openai import OpenAI

client = OpenAI(
    api_key="sk_YOUR_STREAMFIX_KEY",
    base_url="https://streamfix.dev/v1",
)

response = client.chat.completions.create(
    model="openai/gpt-4o-mini",
    messages=[{"role": "user", "content": "Return user data as JSON"}],
    extra_body={
        "schema": {
            "type": "object",
            "properties": {
                "name":   {"type": "string"},
                "age":    {"type": "integer", "minimum": 0},
                "status": {"type": "string", "enum": ["active", "inactive"]}
            },
            "required": ["name", "age", "status"]
        }
    },
)

data = response.choices[0].message.content  # ✅ guaranteed schema-valid JSON
Response headers tell you what happened
# Access via with_raw_response:
raw = client.chat.completions.with_raw_response.create(...)
print(raw.headers["X-StreamFix-Schema-Valid"])  # "true"
print(raw.headers["X-StreamFix-Applied"])       # "fence_strip, type_coerce"
print(raw.headers["X-StreamFix-Retried"])       # "false"
How it works: StreamFix intercepts the LLM response and applies a repair pipeline — strip markdown fences, fix Python literals (True/None), coerce types to match your schema, validate. If validation still fails, it retries the LLM call once with a corrective prompt. Your code only sees the final, validated result.

Side-by-side comparison

Feature Pydantic jsonschema Contract Mode
Type coercion ("25" → 25) Yes (v2) No Yes
Enum validation Yes Yes Yes
Auto-retry on failure No No Yes (1 retry)
Syntax repair No No Yes
Language Python Any Any (HTTP proxy)
Extra LLM call No No Only on retry

Pydantic and jsonschema are client-side libraries. Contract Mode runs server-side at the proxy layer before your code sees the response.

Schema-guaranteed JSON from any LLM

Pass a schema in extra_body and StreamFix guarantees the response matches — repairing, coercing, and retrying as needed. Works with any model, any language, one base_url change.

from openai import OpenAI

client = OpenAI(
    api_key="sk_YOUR_STREAMFIX_KEY",
    base_url="https://streamfix.dev/v1",
)

resp = client.chat.completions.create(
    model="openai/gpt-4o-mini",
    messages=[{"role": "user", "content": "Return user data as JSON"}],
    extra_body={
        "schema": {
            "type": "object",
            "properties": {
                "name":   {"type": "string"},
                "age":    {"type": "integer", "minimum": 0},
                "status": {"type": "string", "enum": ["active", "inactive"]}
            },
            "required": ["name", "age", "status"]
        }
    },
)

data = json.loads(resp.choices[0].message.content)  # ✅ always valid
Get Free API Key →

Related guides