Module 4: Guardrails — Input & Output Validation
4. Output Validation with Pydantic
Description
The LLM can produce malformed JSON, missing fields, incorrect types, or out-of-range values. Pydantic validates the output against a schema defined with types and constraints. But validation isn't just "Pydantic validates or fails" — there are three fallback strategies when it fails: retry, partial parsing, and default. This capsule covers schemas, validators, the three fallback strategies, and how to use OpenAI structured outputs to reduce errors at the source.
The problem it solves
Without output validation, these cases break your app:
# Case 1: Truncated JSON (insufficient max_tokens)
raw = '{"sentiment": "positive", "score": 0.92, "keywords": ["incred'
# JSONDecodeError → your app crashes
# Case 2: Field with incorrect type
raw = '{"sentiment": "positive", "score": "high", "keywords": []}'
# Parse ok, but result["score"] is a string, not a float
# result["score"] * 2 → TypeError in downstream code
# Case 3: Out-of-range field (the LLM hallucinated the value)
raw = '{"sentiment": "positive", "score": 1.5, "keywords": []}'
# Passes JSON parse but score=1.5 violates the 0-1 contract
# Case 4: Unexpected extra field (may not matter, or it may)
raw = '{"sentiment": "positive", "score": 0.9, "keywords": [], "password": "secret"}'
# Does your app expose the "password" in the response to the frontend?
# Case 5: Completely unexpected structure
raw = '{"error": "I didn\'t understand the instruction", "message": "Please repeat"}'
# The LLM didn't follow the format — you need to detect it and handle it
Basic schema with Pydantic
# src/guardrails/output_validator.py
from pydantic import BaseModel, field_validator, model_validator
from typing import Literal, Optional
from enum import Enum
class SentimentOutput(BaseModel):
"""Schema for the sentiment analyzer output."""
# Field 1: Categorical (only 3 allowed values)
sentiment: Literal["positive", "negative", "neutral"]
# Field 2: Float with a constraint
score: float
# Field 3: String with a length constraint
explanation: str
# Field 4: List of strings (can be empty)
keywords: list[str]
# ─── Validators ──────────────────────────────────────────────
@field_validator("score")
@classmethod
def score_must_be_in_range(cls, v: float) -> float:
if not (0.0 <= v <= 1.0):
raise ValueError(f"score must be between 0.0 and 1.0, received: {v}")
return round(v, 4) # Normalize to 4 decimals
@field_validator("explanation")
@classmethod
def explanation_must_have_content(cls, v: str) -> str:
v = v.strip()
if len(v) > 500:
v = v[:500] # Silently truncate if it's too long
return v
@field_validator("keywords")
@classmethod
def keywords_must_be_strings(cls, v: list) -> list[str]:
cleaned = []
for kw in v:
if isinstance(kw, str) and kw.strip():
cleaned.append(kw.strip()[:50]) # Max 50 chars per keyword
return cleaned[:10] # Max 10 keywords
# Model configuration
model_config = {
"extra": "ignore" # Ignore extra fields — don't fail if there are more fields
}
Advanced validators
# Validators with more complex logic:
class SummaryOutput(BaseModel):
summary: str
confidence: float
word_count: int
sources: Optional[list[str]] = None
@field_validator("summary")
@classmethod
def summary_length_check(cls, v: str) -> str:
v = v.strip()
if len(v) < 10:
raise ValueError(f"summary too short: {len(v)} chars")
if len(v) > 2000:
raise ValueError(f"summary too long: {len(v)} chars")
return v
@field_validator("confidence")
@classmethod
def confidence_range(cls, v: float) -> float:
if not (0.0 <= v <= 1.0):
raise ValueError(f"confidence out of range: {v}")
return v
@model_validator(mode="after")
def word_count_matches_summary(self) -> "SummaryOutput":
"""Cross-field validator: checks consistency between fields."""
actual_word_count = len(self.summary.split())
# The LLM reported word_count — verify it's approximately correct
if abs(actual_word_count - self.word_count) > 20:
# Silently correct
self.word_count = actual_word_count
return self
The three fallback strategies
When Pydantic fails, there are three strategies. Choose based on the use case:
Strategy 1: Retry (for transient errors)
import time
from pydantic import ValidationError
def validate_with_retry(
raw: str,
schema: type[BaseModel],
llm_callable,
max_retries: int = 2,
retry_delay: float = 1.0
) -> BaseModel | None:
"""
Validates the output and retries if it fails.
When to use: When the error is probably transient
(truncated JSON, slightly different format).
When NOT to use: When the error is systematic (the LLM never
produces this format) — retrying won't help.
"""
# Try to parse the current raw
try:
return schema.model_validate_json(raw)
except (ValidationError, Exception) as first_error:
pass
# Retry with the LLM (reformulating the prompt)
for attempt in range(max_retries):
try:
retry_prompt = f"Please reformat your response as valid JSON with this exact structure: {schema.model_json_schema()}"
new_raw = llm_callable(retry_prompt)
return schema.model_validate_json(new_raw)
except Exception:
if attempt < max_retries - 1:
time.sleep(retry_delay)
return None # All attempts failed
Strategy 2: Default (for persistent errors)
def validate_with_default(
raw: str,
schema: type[BaseModel],
default: BaseModel
) -> BaseModel:
"""
Validates the output and returns a default if it fails.
When to use: When the system must keep working
even if the LLM fails. The default is a "safe fallback".
When NOT to use: When the failure is critical and returning
a default could mislead the user.
"""
try:
return schema.model_validate_json(raw)
except Exception as e:
import logging
logging.getLogger("guardrails").warning(
"validation_failed_using_default",
extra={"error": str(e), "raw_truncated": raw[:100]}
)
return default
# Usage:
DEFAULT_SENTIMENT = SentimentOutput(
sentiment="neutral",
score=0.5,
explanation="Could not analyze the sentiment of the text.",
keywords=[]
)
result = validate_with_default(
raw=llm_response,
schema=SentimentOutput,
default=DEFAULT_SENTIMENT
)
Strategy 3: Partial parsing (for complex outputs)
import json
import re
def extract_and_validate(
raw: str,
schema: type[BaseModel],
required_fields: list[str] = None
) -> BaseModel | None:
"""
Tries multiple strategies to extract valid JSON.
Pipeline:
1. Direct JSON
2. JSON in a markdown code block
3. JSON searched for in free text
4. Partial parsing with only the required fields
When to use: When the LLM includes JSON inside a longer text,
or when you only need some fields from the output.
"""
# Strategy 1: Parse directly
try:
return schema.model_validate_json(raw.strip())
except Exception:
pass
# Strategy 2: Extract from a markdown code block
markdown_match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', raw, re.DOTALL)
if markdown_match:
try:
return schema.model_validate_json(markdown_match.group(1).strip())
except Exception:
pass
# Strategy 3: Find the first JSON object in the text
json_match = re.search(r'\{.*\}', raw, re.DOTALL)
if json_match:
try:
return schema.model_validate_json(json_match.group())
except Exception:
pass
# Strategy 4: Partial parsing — build with only the available fields
try:
data = {}
# Try to extract key fields with individual regexes
for field_name, field_info in schema.model_fields.items():
# Look for the field in the text as "field_name": value
pattern = f'"{field_name}"\\s*:\\s*([^,}}]+)'
match = re.search(pattern, raw)
if match:
try:
value = json.loads(match.group(1).strip().rstrip(',}'))
data[field_name] = value
except Exception:
pass
if required_fields and not all(f in data for f in required_fields):
return None
return schema.model_validate(data)
except Exception:
return None
OpenAI Structured Outputs: reduce errors at the source
# Option 1: JSON mode (ensures valid JSON, not a specific structure)
response = client.chat.completions.create(
model="gpt-4o-mini",
messages=[{
"role": "user",
"content": "Analyze the sentiment. Respond with JSON: {sentiment, score, explanation, keywords}"
}],
response_format={"type": "json_object"}, # ← Guarantees valid JSON
temperature=0.0
)
# With this: we eliminate JSONDecodeError. Schema validation can still fail.
# Option 2: Structured outputs with a schema (gpt-4o, more robust)
# With this approach the model guarantees it follows the schema:
from pydantic import BaseModel
import openai
response = client.beta.chat.completions.parse(
model="gpt-4o-2024-08-06", # Requires gpt-4o with a specific date
messages=[{
"role": "user",
"content": "Analyze: 'I love this product'"
}],
response_format=SentimentOutput, # ← The Pydantic schema directly
)
result = response.choices[0].message.parsed # ← Already a validated SentimentOutput
Complete output validation function
# src/guardrails/output_validator.py
def validate_llm_output(
raw: str,
schema: type[BaseModel],
strategy: str = "extract_and_default",
default: BaseModel | None = None,
llm_callable = None,
max_retries: int = 1
) -> BaseModel | None:
"""
Validates the LLM output with the specified strategy.
Strategies:
- "strict": Only perfect JSON. Fails otherwise.
- "extract": Tries to extract JSON from free text.
- "retry": Retries with the LLM if it fails.
- "extract_and_default": Extracts, and uses a default if it fails. (Recommended)
"""
import logging
logger = logging.getLogger("guardrails.validator")
if strategy == "strict":
return schema.model_validate_json(raw)
if strategy in ("extract", "extract_and_default"):
result = extract_and_validate(raw, schema)
if result:
return result
if strategy == "extract_and_default" and default:
logger.warning(
"output_validation_failed_using_default",
extra={"raw_preview": raw[:100]}
)
return default
return None
if strategy == "retry" and llm_callable:
return validate_with_retry(raw, schema, llm_callable, max_retries)
return None
Complete validator tests
# tests/unit/guardrails/test_output_validator.py
import pytest
from pydantic import ValidationError
from src.guardrails.output_validator import SentimentOutput, validate_llm_output
class TestSentimentOutputSchema:
"""Tests for the Pydantic schema."""
def test_valid_output_passes(self):
data = {
"sentiment": "positive",
"score": 0.85,
"explanation": "The text uses positive language",
"keywords": ["excellent", "recommended"]
}
result = SentimentOutput(**data)
assert result.sentiment == "positive"
assert result.score == 0.85
def test_invalid_sentiment_fails(self):
with pytest.raises(ValidationError):
SentimentOutput(sentiment="very positive", score=0.9, explanation="OK", keywords=[])
def test_score_above_1_fails(self):
with pytest.raises(ValidationError):
SentimentOutput(sentiment="positive", score=1.5, explanation="OK", keywords=[])
def test_score_below_0_fails(self):
with pytest.raises(ValidationError):
SentimentOutput(sentiment="positive", score=-0.1, explanation="OK", keywords=[])
def test_extra_fields_ignored(self):
"""model_config = extra: ignore — extra fields must not cause an error."""
result = SentimentOutput(
sentiment="positive",
score=0.8,
explanation="OK",
keywords=[],
unexpected_field="value" # Extra field
)
assert result.sentiment == "positive"
assert not hasattr(result, "unexpected_field")
def test_keywords_cleaned(self):
result = SentimentOutput(
sentiment="positive",
score=0.8,
explanation="OK",
keywords=[" keyword1 ", "", " ", "valid"]
)
assert "keyword1" in result.keywords # Trimmed
assert "" not in result.keywords # Empty removed
class TestValidateLlmOutput:
"""Tests for the complete validation function."""
def test_valid_json_passes(self):
raw = '{"sentiment":"positive","score":0.9,"explanation":"OK","keywords":[]}'
result = validate_llm_output(raw, SentimentOutput)
assert result is not None
assert result.sentiment == "positive"
def test_json_in_markdown_extracted(self):
raw = '```json\n{"sentiment":"positive","score":0.9,"explanation":"OK","keywords":[]}\n```'
result = validate_llm_output(raw, SentimentOutput, strategy="extract")
assert result is not None
def test_invalid_json_with_default(self):
from src.guardrails.output_validator import DEFAULT_SENTIMENT
raw = "This is a free-text response, not JSON."
result = validate_llm_output(
raw, SentimentOutput,
strategy="extract_and_default",
default=DEFAULT_SENTIMENT
)
# Should return the default, not None
assert result is not None
assert result.sentiment == "neutral" # The default
def test_truncated_json_handled(self):
"""Truncated JSON must not crash — it must handle it gracefully."""
raw = '{"sentiment": "positive", "score": 0.9, "explanation": "OK", "keywo'
result = validate_llm_output(raw, SentimentOutput, strategy="extract_and_default")
# Must not raise an exception — may return None or the default
# assert result is None # or assert result == DEFAULT_SENTIMENT
Exercises
Exercise 1: Schema for classification
Define a Pydantic schema for document classification:
category: one of["technology", "science", "sports", "politics", "entertainment"]confidence: float 0-1tags: list of strings (maximum 5)language: optional string (may be absent)
See solution
from pydantic import BaseModel, field_validator
from typing import Literal, Optional
class ClassificationOutput(BaseModel):
category: Literal["technology", "science", "sports", "politics", "entertainment"]
confidence: float
tags: list[str]
language: Optional[str] = None
@field_validator("confidence")
@classmethod
def confidence_range(cls, v):
if not (0 <= v <= 1):
raise ValueError(f"confidence out of range: {v}")
return v
@field_validator("tags")
@classmethod
def max_tags(cls, v):
return v[:5] # Maximum 5 tags
model_config = {"extra": "ignore"}
Exercise 2: Fallback strategy
For a user-facing /analyze endpoint, which fallback strategy would you use and why?
See guide
Recommended strategy: extract_and_default
Reasons:
strictwould give the user a 500 error if the LLM fails — bad UXretryadds latency (500ms+ per retry) — bad experienceextract_and_defaulttries the best it can and has a safe fallback
The default for /analyze:
DEFAULT_SENTIMENT = SentimentOutput(
sentiment="neutral",
score=0.5,
explanation="It was not possible to analyze the sentiment at this time.",
keywords=[]
)
The user gets a response, even if it's not perfect. Internally, the error is logged for investigation.
Exercise 3: JSON in markdown
The LLM returns this response:
Here is my analysis:
```json
{"sentiment": "positive", "score": 0.85, "explanation": "Positive text", "keywords": ["good"]}
Anything else?
Write the code to extract and validate the JSON from this response:
<details>
<summary>See solution</summary>
```python
import re
import json
from pydantic import ValidationError
def extract_from_markdown(raw: str) -> SentimentOutput | None:
# Look for JSON in a code block
match = re.search(r'```(?:json)?\s*\n?(.*?)\n?```', raw, re.DOTALL)
if match:
try:
return SentimentOutput.model_validate_json(match.group(1).strip())
except (ValidationError, Exception):
return None
return None
# Test:
raw = '''Here is my analysis:
```json
{"sentiment": "positive", "score": 0.85, "explanation": "Positive text", "keywords": ["good"]}
Anything else?'''
result = extract_from_markdown(raw) assert result is not None assert result.sentiment == "positive"
</details>
---
### Exercise 4: Cross-field model validator
Write a `model_validator` that checks that if `sentiment == "positive"`, the `score` must be >= 0.5 (and vice versa for "negative"):
<details>
<summary>See solution</summary>
```python
from pydantic import model_validator
class SentimentOutputStrict(BaseModel):
sentiment: Literal["positive", "negative", "neutral"]
score: float
explanation: str
keywords: list[str]
@model_validator(mode="after")
def sentiment_score_consistency(self):
if self.sentiment == "positive" and self.score < 0.5:
raise ValueError(
f"Inconsistent: sentiment='positive' but score={self.score} < 0.5"
)
if self.sentiment == "negative" and self.score > 0.5:
raise ValueError(
f"Inconsistent: sentiment='negative' but score={self.score} > 0.5"
)
return self
Summary
- Pydantic validates structure, types, and constraints — the first line of defense for the LLM output
- Three fallback strategies: retry (transient errors), default (persistent errors), partial parsing (embedded JSON)
model_config = {"extra": "ignore"}: avoid failures from unexpected extra fieldsextract_and_validate: handle JSON in markdown code blocks (very common in LLMs)- OpenAI Structured Outputs / JSON mode: reduce errors at the source
model_validatorcross-field: validate consistency between fields when a single one isn't enough
Additional resources
- Pydantic V2 Documentation — Complete reference
- Pydantic field_validator — Per-field validators
- Pydantic model_validator — Cross-field validators
- OpenAI Structured Outputs — For guaranteed outputs
- OpenAI JSON Mode — Basic JSON mode
- Pydantic model_json_schema — Export the schema to use it in prompts