Back to Tracker
Phase 3 — Practical Building

Structured Outputs & Data Extraction

Make LLMs return predictable, parseable data — not free-form text. The skill that turns AI from a toy into a real engineering primitive.

JSON ModePydantic Function CallingExtraction Chains Retry LogicResume Parser
JSON Mode — The Right Way to Get Structured Output CRITICAL
❌ Soft instruction (unreliable)
Just saying "return JSON" in the prompt. The model may add explanation text, use single quotes, or wrap in markdown code fences. json.loads() will randomly fail.
✅ JSON Mode (reliable)
response_format={"type": "json_object"} is enforced by the API — the model is constrained to output valid JSON. json.loads() will always succeed.
import json response = client.chat.completions.create( model="gpt-4o-mini", messages=[ { "role": "system", "content": """Extract the following from the text and return as JSON: {"name": "string", "email": "string or null", "skills": ["list", "of", "strings"]} Rules: use null for missing fields. Return ONLY the JSON object.""" }, {"role": "user", "content": raw_text} ], response_format={"type": "json_object"}, # ← hard constraint, always valid temperature=0.0, # ← deterministic extraction ) data = json.loads(response.choices[0].message.content) # always safe
Important: You MUST instruct the model to return JSON in the prompt (mention "JSON" or "json"). If you don't, the API raises a BadRequestError. The response_format parameter enforces format; the prompt tells the model what structure to use.
Pydantic Validation — Type Safety for LLM Output
from pydantic import BaseModel, Field from typing import Optional class WorkExperience(BaseModel): company: str title: str start_date: Optional[str] = None end_date: Optional[str] = None responsibilities: list[str] = Field(default_factory=list) class Resume(BaseModel): name: str # required — missing = ValidationError email: Optional[str] = None # missing = None, no error phone: Optional[str] = None skills: list[str] = Field(default_factory=list) # missing = [], no error work_experience: list[WorkExperience] = Field(default_factory=list) education: list[str] = Field(default_factory=list) # Parse LLM output into the model data = json.loads(response.choices[0].message.content) resume = Resume(**data) # ValidationError if required fields missing or wrong types # Access with type safety print(resume.name) # str, guaranteed print(resume.email) # str | None print(resume.skills) # list[str] for job in resume.work_experience: print(f"{job.title} at {job.company}")
Design rule: Make fields Optional[...] = None for anything that may not appear in all documents. Only use required fields (no default) when you are 100% certain the data will always be present.
Pydantic Validators — Handle LLM Inconsistencies
from pydantic import BaseModel, Field, field_validator class ExtractedData(BaseModel): # Constrained float — ValidationError if outside range confidence_score: float = Field(ge=0.0, le=1.0) # Non-empty string name: str = Field(min_length=1) # List with at least 1 item skills: list[str] = Field(min_length=1) # Custom validator — normalize "95" → "0.95" (model sometimes returns percentages) @field_validator("confidence_score", mode="before") @classmethod def normalize_confidence(cls, v): if isinstance(v, (int, float)) and v > 1.0: v = v / 100.0 # 95 → 0.95 if not 0.0 <= v <= 1.0: raise ValueError(f"confidence {v} not in [0, 1] after normalization") return round(v, 4) # Normalize string input before type validation @field_validator("name", mode="before") @classmethod def clean_name(cls, v): if isinstance(v, str): return v.strip() return v
mode="before" runs your validator BEFORE Pydantic's type coercion — use this to normalize values in unexpected formats (currency strings, percentages, etc.) before validation fails.
Retry with Error Feedback — 3-Layer Reliability

When Pydantic validation fails, feed the error back to the model and ask it to fix its output:

from pydantic import ValidationError import json def extract_with_retry(text: str, max_retries: int = 2) -> Resume: messages = [ {"role": "system", "content": EXTRACTION_PROMPT}, {"role": "user", "content": text} ] for attempt in range(max_retries): response = client.chat.completions.create( model="gpt-4o-mini", messages=messages, response_format={"type": "json_object"}, temperature=0.0 ) raw_json = response.choices[0].message.content try: return Resume(**json.loads(raw_json)) except (json.JSONDecodeError, ValidationError) as e: if attempt < max_retries - 1: # Feed error back — model corrects itself ~95% of the time messages.append({"role": "assistant", "content": raw_json}) messages.append({ "role": "user", "content": f"Validation failed:\n{str(e)}\n\nPlease fix the JSON and return only the corrected JSON object." }) else: raise ValueError(f"Extraction failed after {max_retries} attempts: {e}")
3-Layer Reliability:
1. JSON Mode — guarantees syntactically valid JSON (eliminates JSONDecodeError)
2. Pydantic — guarantees correct structure and types (catches semantic errors)
3. Retry with feedback — model corrects its own mistakes (~95% success on retry)
Extraction Prompt Template
EXTRACTION_PROMPT = """You are a precise data extractor. Extract information from the provided text. Return JSON with EXACTLY this structure: { "name": "full name or null", "email": "email address or null", "phone": "phone number or null", "skills": ["skill1", "skill2"], "work_experience": [ { "company": "company name", "title": "job title", "start_date": "YYYY-MM or null", "end_date": "YYYY-MM or null (use null for current role)", "responsibilities": ["bullet1", "bullet2"] } ] } Rules: - Use null for any field not found in the text (NOT empty string, NOT "N/A") - Dates in YYYY-MM format (e.g., "2022-03") or null - All monetary values as numbers (float), not strings. Remove $ and commas. - confidence_score as float 0.0-1.0 (NOT a percentage like 95) - Return ONLY the JSON object, no explanation text """
Function Calling vs JSON Mode — When to Use Which
FeatureJSON ModeFunction Calling
Use whenYou need one structured output from textYou have multiple possible actions the model can take
Schema enforcementNone (Pydantic validates after)OpenAI validates against function JSON schema
ComplexitySimple to implementMore setup, more powerful
Best forExtraction, classification, parsingAgents that call real functions (search, DB, APIs)
Rule: Use JSON Mode for extraction tasks. Use Function Calling when building agents that need to decide which action to take from a set of options.
Cheatsheet — One-Pager
ALWAYS USE
response_format={"type": "json_object"} temperature=0.0 # for extraction # Mention JSON in system prompt
PYDANTIC PATTERN
class MyModel(BaseModel): required: str optional: Optional[str] = None items: list[str] = Field( default_factory=list) data = MyModel(**json.loads(raw)) # ValidationError if structure wrong
RETRY PATTERN
for attempt in range(2): raw = llm_call(messages) try: return MyModel(**json.loads(raw)) except ValidationError as e: messages += [ {"role":"assistant","content":raw}, {"role":"user","content": f"Fix this error: {e}"} ] raise ValueError("Extraction failed")
VALIDATOR PATTERN
@field_validator("score", mode="before") @classmethod def normalize(cls, v): if isinstance(v, (int, float)) and v > 1: v = v / 100.0 # 95 → 0.95 return v