How to Use ChatGPT Structured Outputs for Reliable JSON: Complete API Tutorial with Schema Validation and Error Handling

How to Use ChatGPT Structured Outputs for Reliable JSON: Complete API Tutorial with Schema Validation and Error Handling

Building production applications on top of large language models has historically meant wrestling with unpredictable JSON output. You define a schema, ask the model to follow it, and somewhere between your prompt and the API response, fields go missing, types get mangled, and your application throws an exception at 2 AM. OpenAI’s Structured Outputs feature changes everything. Introduced in August 2024 and now available across GPT-4o and GPT-4o-mini model families, Structured Outputs enforces 100% schema compliance at the constrained decoding level—not just as a best-effort instruction. This tutorial walks through every aspect of the feature, from basic setup through advanced recursive schemas, with production-ready code in Python and TypeScript throughout.

How to Use ChatGPT Structured Outputs for Reliable JSON: Complete API Tutorial with Schema Validation and Error Handling

1. What Are Structured Outputs and Why They Matter

Structured Outputs is an OpenAI API feature that constrains model responses to a developer-specified JSON Schema using a technique called constrained decoding. At inference time, the model’s token sampling is guided by a finite-state machine derived from your schema. Tokens that would produce invalid JSON—or JSON that doesn’t conform to your schema—are assigned a probability of zero and simply cannot be selected.

This is fundamentally different from prompt engineering approaches like “respond only with valid JSON matching this schema.” Prompt-based approaches are probabilistic: they work most of the time but fail unpredictably under load, with long contexts, or when the model is “confused” by complex instructions. Structured Outputs eliminates that class of failure entirely.

The Problem with Traditional JSON Prompting

Before Structured Outputs, developers used several workarounds, each with significant limitations:

  • Prompt engineering: “Return only valid JSON with these fields.” Failure rate: 2–15% depending on schema complexity and model version.
  • JSON mode (response_format: json_object): Guarantees valid JSON syntax but not schema compliance. The model can still omit required fields or use wrong types.
  • Retry loops: Parse the response, validate against a schema, retry on failure. Doubles latency and cost on failures.
  • Post-processing: Manually coerce types and fill missing fields. Fragile and business-logic-dependent.

Structured Outputs provides a clean solution: define a schema once, and every response is guaranteed to match it. According to OpenAI’s published benchmarks, Structured Outputs achieves a 100% compliance rate on well-formed schemas, compared to approximately 85% for JSON mode and 65–80% for pure prompt engineering on complex schemas.

Supported Models

Model Structured Outputs Support Notes
gpt-4o (2024-08-06+) ✅ Full support Recommended for production
gpt-4o-mini (2024-07-18+) ✅ Full support Best cost/performance ratio
o1 models ✅ Full support High-reasoning tasks
gpt-4-turbo ❌ JSON mode only No schema enforcement
gpt-3.5-turbo ❌ JSON mode only No schema enforcement

2. Setup and Prerequisites

Before writing a single line of schema code, you need the right library versions. OpenAI’s Structured Outputs requires SDK support for parsing responses into typed objects.

Python Setup

# Requires openai >= 1.40.0
pip install openai>=1.40.0 pydantic>=2.0.0

# For schema validation and testing
pip install jsonschema pytest httpx
import os
from openai import OpenAI
from pydantic import BaseModel, Field
from typing import Optional, List, Literal
import json

client = OpenAI(api_key=os.environ.get("OPENAI_API_KEY"))

# Verify the connection
def health_check():
    response = client.models.list()
    structured_models = [m.id for m in response.data 
                        if "gpt-4o" in m.id or "o1" in m.id]
    print(f"Available structured output models: {structured_models}")

health_check()

TypeScript / Node.js Setup

# Requires openai >= 4.55.0
npm install openai@latest zod typescript ts-node @types/node

# tsconfig.json should include:
# "strict": true, "target": "ES2020", "moduleResolution": "bundler"
// src/client.ts
import OpenAI from "openai";
import { z } from "zod";

export const client = new OpenAI({
  apiKey: process.env.OPENAI_API_KEY,
});

// Type helper for structured response parsing
export type ParsedResponse<T> = {
  data: T | null;
  refusal: string | null;
  usage: OpenAI.CompletionUsage | undefined;
};

3. Defining JSON Schemas: The Foundation

Structured Outputs uses a subset of JSON Schema Draft 2020-12. Not every JSON Schema keyword is supported—understanding the constraints upfront prevents frustrating debugging sessions. The supported subset is intentionally conservative to ensure the constrained decoding algorithm can efficiently compute valid token sets.

Supported Keywords

  • Type keywords: string, number, integer, boolean, array, object, null
  • Object keywords: properties, required, additionalProperties (must be false)
  • Array keywords: items, prefixItems, minItems, maxItems
  • String keywords: enum, maxLength, format (informational only)
  • Composition: anyOf (for union types and optional fields)

Unsupported Keywords (Common Gotchas)

  • oneOf, allOf, not — use anyOf instead
  • if/then/else — conditional schemas not supported
  • $ref to external schemas — inline definitions only
  • pattern for string regex constraints
  • additionalProperties: true — must be false or omitted (defaults to false)

A critical rule: every object in your schema must have additionalProperties: false and must list all properties in the required array. This is the most common error when migrating existing schemas to Structured Outputs.

# Python: Schema defined as a Python dict
product_schema = {
    "type": "object",
    "properties": {
        "name": {"type": "string"},
        "price": {"type": "number"},
        "category": {
            "type": "string",
            "enum": ["electronics", "clothing", "food", "books"]
        },
        "in_stock": {"type": "boolean"},
        "tags": {
            "type": "array",
            "items": {"type": "string"}
        }
    },
    "required": ["name", "price", "category", "in_stock", "tags"],
    "additionalProperties": False
}

Using Pydantic for Schema Generation (Python)

The cleanest Python approach is using Pydantic models, which the OpenAI SDK can automatically convert to JSON Schema with the correct Structured Outputs constraints.

from pydantic import BaseModel, Field
from typing import List, Literal, Optional
from enum import Enum

class ProductCategory(str, Enum):
    ELECTRONICS = "electronics"
    CLOTHING = "clothing"
    FOOD = "food"
    BOOKS = "books"

class Product(BaseModel):
    name: str = Field(description="Product name as listed in catalog")
    price: float = Field(ge=0, description="Price in USD")
    category: ProductCategory
    in_stock: bool
    tags: List[str] = Field(default_factory=list)
    
    model_config = {"json_schema_extra": {"additionalProperties": False}}

# Preview the generated schema
import json
print(json.dumps(Product.model_json_schema(), indent=2))

Using Zod for Schema Generation (TypeScript)

// src/schemas/product.ts
import { z } from "zod";
import zodToJsonSchema from "zod-to-json-schema";

const ProductCategory = z.enum(["electronics", "clothing", "food", "books"]);

export const ProductSchema = z.object({
  name: z.string().describe("Product name as listed in catalog"),
  price: z.number().nonnegative().describe("Price in USD"),
  category: ProductCategory,
  in_stock: z.boolean(),
  tags: z.array(z.string()),
});

export type Product = z.infer<typeof ProductSchema>;

// Convert to JSON Schema for OpenAI API
export const productJsonSchema = zodToJsonSchema(ProductSchema, {
  name: "Product",
  $refStrategy: "none", // Inline all refs for Structured Outputs compatibility
});

How to Use ChatGPT Structured Outputs for Reliable JSON: Complete API Tutorial with Schema Validation and Error Handling - Section 1

4. Using the response_format Parameter and Strict Mode

The response_format parameter is where Structured Outputs is activated. Setting type: "json_schema" and strict: true enables full constrained decoding. Without strict: true, you get best-effort schema adherence (similar to the old JSON mode behavior).

Python: Basic Structured Output Call

from openai import OpenAI
from pydantic import BaseModel
from typing import List

client = OpenAI()

class ExtractedEntity(BaseModel):
    name: str
    entity_type: str
    confidence: float
    context: str

class EntityExtractionResult(BaseModel):
    entities: List[ExtractedEntity]
    total_count: int
    processing_notes: str

def extract_entities(text: str) -> EntityExtractionResult:
    completion = client.beta.chat.completions.parse(
        model="gpt-4o-2024-08-06",
        messages=[
            {
                "role": "system",
                "content": (
                    "You are an entity extraction system. Extract all named entities "
                    "from the provided text. Include people, organizations, locations, "
                    "products, and events. Set confidence between 0.0 and 1.0."
                )
            },
            {"role": "user", "content": text}
        ],
        response_format=EntityExtractionResult,
        temperature=0.1  # Low temperature for extraction tasks
    )
    
    message = completion.choices[0].message
    
    # Handle refusals (covered in detail in error handling section)
    if message.refusal:
        raise ValueError(f"Model refused request: {message.refusal}")
    
    return message.parsed

# Usage
result = extract_entities(
    "Apple CEO Tim Cook announced the iPhone 16 launch in Cupertino, California "
    "on September 9, 2024. Google's Sundar Pichai attended the event remotely."
)
print(f"Found {result.total_count} entities")
for entity in result.entities:
    print(f"  {entity.name} ({entity.entity_type}): {entity.confidence:.0%} confidence")

TypeScript: Structured Output Call with Manual Schema

// src/extractEntities.ts
import OpenAI from "openai";

const client = new OpenAI();

interface ExtractedEntity {
  name: string;
  entity_type: string;
  confidence: number;
  context: string;
}

interface EntityExtractionResult {
  entities: ExtractedEntity[];
  total_count: number;
  processing_notes: string;
}

const entitySchema = {
  type: "object" as const,
  properties: {
    entities: {
      type: "array",
      items: {
        type: "object",
        properties: {
          name: { type: "string" },
          entity_type: { type: "string" },
          confidence: { type: "number" },
          context: { type: "string" }
        },
        required: ["name", "entity_type", "confidence", "context"],
        additionalProperties: false
      }
    },
    total_count: { type: "integer" },
    processing_notes: { type: "string" }
  },
  required: ["entities", "total_count", "processing_notes"],
  additionalProperties: false
};

async function extractEntities(text: string): Promise<EntityExtractionResult> {
  const completion = await client.chat.completions.create({
    model: "gpt-4o-2024-08-06",
    messages: [
      {
        role: "system",
        content: "Extract named entities from the provided text."
      },
      { role: "user", content: text }
    ],
    response_format: {
      type: "json_schema",
      json_schema: {
        name: "entity_extraction_result",
        strict: true,
        schema: entitySchema
      }
    },
    temperature: 0.1
  });

  const content = completion.choices[0].message.content;
  if (!content) throw new Error("Empty response from API");
  
  return JSON.parse(content) as EntityExtractionResult;
}

5. Handling Nested Objects and Arrays

Nested structures are where Structured Outputs really demonstrates its value over prompt engineering. The constrained decoder handles arbitrary nesting depth without degradation in compliance rate, something that’s impossible to achieve with prompts alone.

Deep Nesting Example: Structured Report

from pydantic import BaseModel
from typing import List, Optional
from enum import Enum

class Severity(str, Enum):
    LOW = "low"
    MEDIUM = "medium"
    HIGH = "high"
    CRITICAL = "critical"

class Finding(BaseModel):
    id: str
    title: str
    description: str
    severity: Severity
    affected_components: List[str]
    remediation_steps: List[str]

class ScanMetadata(BaseModel):
    scan_id: str
    timestamp: str
    duration_seconds: int
    files_scanned: int
    model_version: str

class SecurityReport(BaseModel):
    metadata: ScanMetadata
    executive_summary: str
    findings: List[Finding]
    total_findings: int
    critical_count: int
    high_count: int
    remediation_priority: List[str]
    next_scan_recommended: str

def generate_security_report(codebase_description: str) -> SecurityReport:
    completion = client.beta.chat.completions.parse(
        model="gpt-4o-2024-08-06",
        messages=[
            {
                "role": "system",
                "content": (
                    "You are a security analysis system. Generate a structured security "
                    "report based on the codebase description provided. Use realistic "
                    "finding IDs in format VULN-YYYY-XXXX."
                )
            },
            {"role": "user", "content": codebase_description}
        ],
        response_format=SecurityReport
    )
    return completion.choices[0].message.parsed

Variable-Length Arrays with Constraints

# TypeScript: Array with minItems/maxItems constraints
const rankingSchema = {
  type: "object",
  properties: {
    topic: { type: "string" },
    ranked_items: {
      type: "array",
      items: {
        type: "object",
        properties: {
          rank: { type: "integer" },
          name: { type: "string" },
          score: { type: "number" },
          reasoning: { type: "string" }
        },
        required: ["rank", "name", "score", "reasoning"],
        additionalProperties: false
      },
      minItems: 3,
      maxItems: 10
    },
    methodology: { type: "string" }
  },
  required: ["topic", "ranked_items", "methodology"],
  additionalProperties: false
};

6. Enum Types and Constrained Values

Enums are one of the most powerful tools in Structured Outputs schemas. By constraining a field to a specific set of string values, you enable downstream code to use exhaustive switch statements and eliminate entire categories of runtime errors.

from pydantic import BaseModel
from typing import Literal, List

class SentimentAnalysis(BaseModel):
    # Literal types map directly to JSON Schema enum
    overall_sentiment: Literal["very_positive", "positive", "neutral", "negative", "very_negative"]
    confidence: float
    
    # Aspect-level sentiment
    aspects: List["AspectSentiment"]
    
    # ISO 639-1 language codes as enum
    detected_language: Literal["en", "es", "fr", "de", "zh", "ja", "ar", "pt"]
    
    # Structured emotion detection
    primary_emotion: Literal[
        "joy", "sadness", "anger", "fear", "surprise", 
        "disgust", "anticipation", "trust", "neutral"
    ]

class AspectSentiment(BaseModel):
    aspect: str
    sentiment: Literal["positive", "neutral", "negative"]
    mentioned_phrases: List[str]

SentimentAnalysis.model_rebuild()
// TypeScript: Using z.enum for constrained values
import { z } from "zod";

const SentimentLevel = z.enum([
  "very_positive", "positive", "neutral", "negative", "very_negative"
]);

const ISOLanguage = z.enum(["en", "es", "fr", "de", "zh", "ja", "ar", "pt"]);

const ClassificationSchema = z.object({
  document_type: z.enum([
    "invoice", "contract", "email", "report", 
    "form", "receipt", "letter", "memo"
  ]),
  sentiment: SentimentLevel,
  language: ISOLanguage,
  priority: z.enum(["urgent", "high", "normal", "low"]),
  requires_human_review: z.boolean(),
  department_routing: z.enum([
    "sales", "legal", "finance", "hr", "engineering", "support"
  ]),
  confidence_score: z.number()
});

7. Error Handling: Refusals, Partial Outputs, and Validation Failures

Even with 100% schema compliance, your error handling must account for three distinct failure modes: model refusals, network/API errors, and the rare edge case of schema construction errors.

Handling Model Refusals

When a model determines a request violates usage policies, it returns a refusal instead of parsed content. The OpenAI SDK surfaces this in the message.refusal field. Critically, message.parsed will be None/null when a refusal occurs.

Access 40,000+ AI Prompts for ChatGPT, Claude & Codex — Free!

Subscribe to get instant access to our complete Notion Prompt Library — the largest curated collection of prompts for ChatGPT, Claude, OpenAI Codex, and other leading AI models. Optimized for real-world workflows across coding, research, content creation, and business.

Get Free Access Now →

from openai import OpenAI
from pydantic import BaseModel
from typing import Optional
import logging

logger = logging.getLogger(__name__)

class StructuredOutputError(Exception):
    def __init__(self, message: str, error_type: str, raw_response=None):
        super().__init__(message)
        self.error_type = error_type
        self.raw_response = raw_response

def safe_parse(
    model: type[BaseModel],
    messages: list[dict],
    model_name: str = "gpt-4o-2024-08-06",
    max_retries: int = 2
) -> BaseModel:
    """
    Production-safe wrapper for structured output parsing.
    Handles refusals, API errors, and unexpected None responses.
    """
    for attempt in range(max_retries + 1):
        try:
            completion = client.beta.chat.completions.parse(
                model=model_name,
                messages=messages,
                response_format=model
            )
            
            choice = completion.choices[0]
            message = choice.message
            
            # Check for content filter refusal
            if message.refusal is not None:
                logger.warning(f"Model refusal on attempt {attempt + 1}: {message.refusal}")
                raise StructuredOutputError(
                    f"Model refused to generate structured output: {message.refusal}",
                    error_type="refusal",
                    raw_response=completion
                )
            
            # Check finish reason
            if choice.finish_reason == "length":
                logger.error("Response truncated due to max_tokens limit")
                raise StructuredOutputError(
                    "Response was truncated. Increase max_tokens or simplify the schema.",
                    error_type="truncation",
                    raw_response=completion
                )
            
            # Ensure parsed is not None
            if message.parsed is None:
                logger.error(f"Parsed response is None. Raw content: {message.content[:200]}")
                raise StructuredOutputError(
                    "Parsed response was None despite no refusal",
                    error_type="parse_failure",
                    raw_response=completion
                )
            
            logger.info(f"Successful parse on attempt {attempt + 1}")
            return message.parsed
            
        except StructuredOutputError:
            if attempt == max_retries:
                raise
            logger.warning(f"Retrying after error on attempt {attempt + 1}")
            continue
            
        except Exception as e:
            logger.error(f"Unexpected API error: {type(e).__name__}: {e}")
            raise StructuredOutputError(
                f"API call failed: {str(e)}",
                error_type="api_error"
            ) from e

TypeScript Error Handling

// src/utils/safeStructuredOutput.ts
import OpenAI from "openai";

type StructuredOutputErrorType = 
  | "refusal" 
  | "truncation" 
  | "parse_failure" 
  | "api_error" 
  | "validation_error";

export class StructuredOutputError extends Error {
  constructor(
    message: string,
    public readonly errorType: StructuredOutputErrorType,
    public readonly rawResponse?: unknown
  ) {
    super(message);
    this.name = "StructuredOutputError";
  }
}

export async function safeStructuredOutput<T>(
  client: OpenAI,
  params: OpenAI.Chat.ChatCompletionCreateParamsNonStreaming,
  validator?: (data: unknown) => T
): Promise<T> {
  const completion = await client.chat.completions.create(params);
  
  const choice = completion.choices[0];
  
  if (choice.finish_reason === "content_filter") {
    throw new StructuredOutputError(
      "Request was blocked by content filter",
      "refusal",
      completion
    );
  }
  
  if (choice.finish_reason === "length") {
    throw new StructuredOutputError(
      "Response truncated. Increase max_tokens.",
      "truncation",
      completion
    );
  }
  
  const content = choice.message.content;
  if (!content) {
    throw new StructuredOutputError(
      "Empty response content",
      "parse_failure",
      completion
    );
  }
  
  let parsed: unknown;
  try {
    parsed = JSON.parse(content);
  } catch (e) {
    throw new StructuredOutputError(
      `JSON parse failed: ${e}`,
      "parse_failure",
      content
    );
  }
  
  if (validator) {
    try {
      return validator(parsed);
    } catch (e) {
      throw new StructuredOutputError(
        `Schema validation failed: ${e}`,
        "validation_error",
        parsed
      );
    }
  }
  
  return parsed as T;
}

How to Use ChatGPT Structured Outputs for Reliable JSON: Complete API Tutorial with Schema Validation and Error Handling - Section 2

8. Real-World Examples: 10 Production-Ready Schemas

The following schemas cover the most common use cases encountered in production LLM applications. Each includes the complete Pydantic model and a sample extraction call.

OpenAI API Function Calling Complete Guide

Schema 1: E-Commerce Product Extraction

class ProductDimensions(BaseModel):
    width_cm: float
    height_cm: float
    depth_cm: float
    weight_kg: float

class ProductListing(BaseModel):
    sku: str
    name: str
    brand: str
    description: str
    price_usd: float
    original_price_usd: float
    discount_percentage: float
    category: Literal["electronics", "clothing", "home", "sports", "beauty", "food"]
    subcategory: str
    tags: List[str]
    dimensions: ProductDimensions
    in_stock: bool
    stock_count: int
    rating: float
    review_count: int
    features: List[str]
    warnings: List[str]

Schema 2: Resume / CV Parser

class WorkExperience(BaseModel):
    company: str
    title: str
    start_date: str  # ISO 8601: YYYY-MM
    end_date: str    # "present" or ISO 8601
    location: str
    responsibilities: List[str]
    technologies: List[str]
    achievements: List[str]

class Education(BaseModel):
    institution: str
    degree: str
    field_of_study: str
    graduation_year: int
    gpa: str  # Optional, use empty string if not mentioned

class ParsedResume(BaseModel):
    full_name: str
    email: str
    phone: str
    location: str
    linkedin_url: str
    github_url: str
    summary: str
    years_of_experience: int
    seniority_level: Literal["intern", "junior", "mid", "senior", "lead", "principal", "executive"]
    work_experience: List[WorkExperience]
    education: List[Education]
    skills: List[str]
    certifications: List[str]
    languages: List[str]

Schema 3: Legal Contract Analysis

class ContractParty(BaseModel):
    name: str
    role: Literal["client", "vendor", "employer", "employee", "licensor", "licensee", "other"]
    entity_type: Literal["individual", "corporation", "llc", "partnership", "government"]
    jurisdiction: str

class KeyTerm(BaseModel):
    term_name: str
    definition: str
    section_reference: str
    risk_level: Literal["low", "medium", "high"]

class ContractAnalysis(BaseModel):
    contract_type: Literal[
        "employment", "service_agreement", "nda", "saas", 
        "licensing", "purchase", "partnership", "other"
    ]
    effective_date: str
    expiration_date: str
    auto_renewal: bool
    parties: List[ContractParty]
    governing_law: str
    key_terms: List[KeyTerm]
    payment_terms: str
    termination_conditions: List[str]
    liability_caps: str
    intellectual_property_clauses: List[str]
    red_flags: List[str]
    overall_risk_score: Literal["low", "medium", "high", "very_high"]
    recommended_changes: List[str]

Schema 4: Customer Support Ticket Classifier

class SupportTicket(BaseModel):
    ticket_id: str
    category: Literal[
        "billing", "technical_support", "account_access", 
        "feature_request", "bug_report", "general_inquiry", "complaint"
    ]
    subcategory: str
    priority: Literal["p0_critical", "p1_high", "p2_medium", "p3_low"]
    sentiment: Literal["very_frustrated", "frustrated", "neutral", "satisfied", "very_satisfied"]
    requires_escalation: bool
    escalation_reason: str
    suggested_department: Literal["tier1", "tier2", "billing", "engineering", "management"]
    estimated_resolution_time: str
    key_issues: List[str]
    customer_intent: Literal["needs_help", "wants_refund", "wants_feature", "reporting_bug", "complaint"]
    action_items: List[str]
    auto_response_applicable: bool

Schema 5: Medical Symptom Extractor

class Symptom(BaseModel):
    symptom_name: str
    severity: Literal["mild", "moderate", "severe"]
    duration: str
    frequency: Literal["constant", "intermittent", "occasional", "rare"]
    onset: Literal["sudden", "gradual", "unknown"]
    associated_factors: List[str]

class MedicalHistory(BaseModel):
    # Note: Always add appropriate disclaimers in production
    chief_complaint: str
    symptoms: List[Symptom]
    duration_of_illness: str
    relevant_history: List[str]
    current_medications: List[str]
    allergies: List[str]
    vital_signs_mentioned: List[str]
    red_flag_symptoms: List[str]
    requires_urgent_care: bool
    urgency_reasoning: str

Schema 6: Financial Report Extractor

class FinancialMetric(BaseModel):
    metric_name: str
    value: float
    unit: Literal["usd", "eur", "gbp", "percentage", "ratio", "count", "shares"]
    period: str
    year_over_year_change: float
    meets_guidance: bool

class FinancialReport(BaseModel):
    company_name: str
    ticker_symbol: str
    report_type: Literal["10-K", "10-Q", "8-K", "earnings_call", "annual_report"]
    fiscal_period: str
    fiscal_year: int
    revenue: float
    gross_profit: float
    operating_income: float
    net_income: float
    eps_basic: float
    eps_diluted: float
    revenue_growth_yoy: float
    key_metrics: List[FinancialMetric]
    guidance_raised: bool
    guidance_lowered: bool
    management_outlook: Literal["very_bullish", "bullish", "neutral", "cautious", "bearish"]
    key_risks: List[str]
    strategic_highlights: List[str]

Schemas 7-10 (Condensed)

# Schema 7: API Response Generator
class APIResponse(BaseModel):
    status_code: Literal[200, 201, 400, 401, 403, 404, 422, 429, 500, 503]
    success: bool
    message: str
    data: str  # JSON string for nested data
    errors: List[str]
    warnings: List[str]
    pagination: str  # JSON string: {"page": 1, "per_page": 20, "total": 100}
    metadata: str   # JSON string with request metadata

# Schema 8: Form Data Extractor
class FormField(BaseModel):
    field_name: str
    field_type: Literal["text", "email", "phone", "date", "number", "boolean", "select", "textarea"]
    value: str
    is_required: bool
    is_valid: bool
    validation_error: str

class ExtractedFormData(BaseModel):
    form_type: Literal["contact", "registration", "order", "survey", "application", "feedback"]
    fields: List[FormField]
    completion_percentage: float
    missing_required_fields: List[str]
    submission_ready: bool

# Schema 9: Code Review
class CodeIssue(BaseModel):
    line_number: int
    severity: Literal["error", "warning", "info", "style"]
    rule_id: str
    message: str
    suggestion: str
    category: Literal["security", "performance", "maintainability", "correctness", "style"]

class CodeReview(BaseModel):
    language: str
    overall_quality: Literal["excellent", "good", "acceptable", "needs_work", "poor"]
    issues: List[CodeIssue]
    security_vulnerabilities: List[str]
    performance_concerns: List[str]
    test_coverage_assessment: str
    refactoring_suggestions: List[str]
    complexity_score: int  # 1-10

# Schema 10: News Article Analyzer
class NewsAnalysis(BaseModel):
    headline: str
    summary: str
    article_type: Literal["news", "opinion", "analysis", "press_release", "feature", "interview"]
    topics: List[str]
    named_entities: List[str]
    sentiment: Literal["positive", "negative", "neutral", "mixed"]
    bias_indicators: List[str]
    factual_claims: List[str]
    sources_cited: List[str]
    publication_date: str
    credibility_score: float
    key_quotes: List[str]

9. Function Calling vs. Structured Outputs: When to Use Each

ChatGPT API Function Calling vs Tool Use Comparison

Function calling and Structured Outputs both produce structured data, but they serve different architectural patterns. Understanding the distinction prevents over-engineering.

Dimension Function Calling Structured Outputs
Primary purpose Trigger external actions/tools Extract/generate structured data
Schema enforcement Best-effort (without strict:true) 100% guaranteed with strict:true
Multiple schemas Yes, multiple tools One schema per request
Async tool execution Yes, designed for it No
Latency overhead Low (first pass only) ~10-20% higher (constrained decoding)
Token efficiency Slightly higher (tool metadata) Efficient for data extraction
Streaming support Partial Yes, stream: true supported
Best for Agentic workflows, tool use Data extraction, form filling, reports

Function calling also supports strict: true mode as of August 2024, providing schema enforcement for tool parameters. The key architectural decision: if your application needs to do something with the model output (call an API, query a database, execute code), use function calling. If it needs to extract or generate structured data, use Structured Outputs.

# Hybrid approach: Function calling for actions + Structured Outputs for data
# Use function calling when you need the model to decide WHICH action to take
tools = [
    {
        "type": "function",
        "function": {
            "name": "search_products",
            "description": "Search product catalog",
            "strict": True,  # Function calling with strict mode
            "parameters": {
                "type": "object",
                "properties": {
                    "query": {"type": "string"},
                    "category": {"type": "string", "enum": ["electronics", "clothing"]},
                    "max_price": {"type": "number"},
                    "in_stock_only": {"type": "boolean"}
                },
                "required": ["query", "category", "max_price", "in_stock_only"],
                "additionalProperties": False
            }
        }
    }
]

# Then use Structured Outputs to parse the search results into a typed report

10. Advanced Patterns: Recursive Schemas, Union Types, Optional Fields

Optional Fields with anyOf

Since all properties must be listed in required, optional fields are expressed using anyOf with a null type. This is Structured Outputs’ idiom for nullable/optional values.

from typing import Optional, List
from pydantic import BaseModel

class UserProfile(BaseModel):
    # Required fields - will always be present
    user_id: str
    email: str
    created_at: str
    
    # Optional fields - must use Optional[T] which becomes anyOf: [T, null]
    display_name: Optional[str] = None
    bio: Optional[str] = None
    avatar_url: Optional[str] = None
    phone_number: Optional[str] = None
    date_of_birth: Optional[str] = None  # ISO 8601 or null
    
    # Optional complex type
    subscription: Optional["SubscriptionInfo"] = None
    
class SubscriptionInfo(BaseModel):
    plan: Literal["free", "pro", "enterprise"]
    expires_at: str
    features: List[str]

UserProfile.model_rebuild()
// TypeScript: Optional fields with Zod
const UserProfileSchema = z.object({
  user_id: z.string(),
  email: z.string(),
  created_at: z.string(),
  
  // Optional fields become anyOf: [type, null] in JSON Schema
  display_name: z.string().nullable(),
  bio: z.string().nullable(),
  avatar_url: z.string().nullable(),
  phone_number: z.string().nullable(),
});

// When converting to JSON Schema, nullable() generates the correct anyOf pattern

Union Types with anyOf

from typing import Union, Annotated
from pydantic import BaseModel, Field

class TextContent(BaseModel):
    content_type: Literal["text"]
    text: str
    word_count: int

class ImageContent(BaseModel):
    content_type: Literal["image"]
    url: str
    alt_text: str
    width: int
    height: int

class CodeContent(BaseModel):
    content_type: Literal["code"]
    language: str
    code: str
    explanation: str

# Union type - Pydantic uses a discriminator for clean anyOf generation
ContentBlock = Annotated[
    Union[TextContent, ImageContent, CodeContent],
    Field(discriminator="content_type")
]

class Document(BaseModel):
    title: str
    author: str
    blocks: List[ContentBlock]
    total_blocks: int

Recursive Schemas (Tree Structures)

Recursive schemas require careful handling because JSON Schema doesn’t support infinite recursion. Use a depth limit and forward references.

from typing import Optional, List
from pydantic import BaseModel

# For truly recursive structures, use a depth-limited approach
class CategoryNode(BaseModel):
    id: str
    name: str
    description: str
    level: int  # 0 = root, 1 = child, 2 = grandchild
    parent_id: Optional[str] = None
    children: List["CategoryNode"]  # Recursive!
    
    model_config = {"arbitrary_types_allowed": True}

CategoryNode.model_rebuild()

# For API calls, be explicit about max depth in your system prompt:
# "Generate a category tree with maximum 3 levels of nesting"

11. Performance Considerations and Pricing Implications

Latency Impact

Constrained decoding adds computational overhead because at each token generation step, the model must compute the valid token set from the schema’s finite-state machine. OpenAI’s internal benchmarks and community measurements show the following patterns:

Schema Complexity Latency Overhead (vs JSON mode) First Token Delay
Simple (5-10 fields) ~5-10% Negligible
Medium (20-50 fields) ~10-20% 50-100ms
Complex (100+ fields, deep nesting) ~20-35% 100-300ms
Large enum sets (100+ values) ~15-25% Variable

OpenAI caches compiled schemas server-side, so the first request with a new schema has higher latency than subsequent requests. Schema caching is automatic—the same schema used across multiple requests benefits from this optimization.

Pricing

Structured Outputs does not incur additional per-request fees beyond standard token pricing. The schema definition in response_format counts toward input tokens. A typical medium-complexity schema adds 200-500 input tokens per request. At GPT-4o pricing ($2.50/1M input tokens), 1,000 requests with a 300-token schema costs approximately $0.75 in extra input token costs—negligible for most applications.

# Python: Token counting for schema overhead estimation
import tiktoken

def estimate_schema_tokens(schema: dict) -> int:
    """Estimate token cost of including a schema in the API request."""
    enc = tiktoken.encoding_for_model("gpt-4o")
    schema_json = json.dumps(schema, separators=(',', ':'))  # Compact JSON
    tokens = len(enc.encode(schema_json))
    return tokens

def calculate_monthly_cost(
    schema: dict,
    daily_requests: int,
    avg_input_tokens: int = 500,
    avg_output_tokens: int = 300,
    price_per_million_input: float = 2.50,
    price_per_million_output: float = 10.00
) -> dict:
    schema_tokens = estimate_schema_tokens(schema)
    total_input = (avg_input_tokens + schema_tokens) * daily_requests * 30
    total_output = avg_output_tokens * daily_requests * 30
    
    input_cost = (total_input / 1_000_000) * price_per_million_input
    output_cost = (total_output / 1_000_000) * price_per_million_output
    
    return {
        "schema_tokens": schema_tokens,
        "monthly_input_tokens": total_input,
        "monthly_output_tokens": total_output,
        "monthly_input_cost_usd": round(input_cost, 4),
        "monthly_output_cost_usd": round(output_cost, 4),
        "total_monthly_cost_usd": round(input_cost + output_cost, 4)
    }

12. Testing and Validation Strategies

Testing AI Applications with Pytest and Mock Strategies

Testing Structured Outputs applications requires a layered strategy: unit testing schema definitions, integration testing API responses, and regression testing output quality.

# tests/test_structured_outputs.py
import pytest
import json
import jsonschema
from pydantic import ValidationError
from unittest.mock import MagicMock, patch

# Test 1: Schema validity - ensure your schemas are valid JSON Schema
def test_schema_is_valid_json_schema():
    """Verify schema passes JSON Schema meta-validation"""
    from schemas import EntityExtractionResult
    schema = EntityExtractionResult.model_json_schema()
    
    # Validate the schema itself is valid JSON Schema
    jsonschema.validators.validator_for(schema).check_schema(schema)

# Test 2: Schema constraints for Structured Outputs compliance
def test_schema_has_no_additional_properties():
    """All objects in schema must have additionalProperties: false"""
    from schemas import SecurityReport
    schema = SecurityReport.model_json_schema()
    
    def check_no_additional_properties(obj, path="root"):
        if obj.get("type") == "object":
            assert obj.get("additionalProperties") == False, \
                f"Object at '{path}' is missing additionalProperties: false"
            for prop_name, prop_schema in obj.get("properties", {}).items():
                check_no_additional_properties(prop_schema, f"{path}.{prop_name}")
        elif obj.get("type") == "array":
            check_no_additional_properties(obj.get("items", {}), f"{path}[]")
    
    check_no_additional_properties(schema)

# Test 3: All required fields present in parsed output
def test_pydantic_model_instantiation():
    """Verify model can be instantiated with all required fields"""
    from schemas import ProductListing
    
    sample_data = {
        "sku": "PROD-001",
        "name": "Test Product",
        "brand": "TestBrand",
        "description": "A test product",
        "price_usd": 29.99,
        "original_price_usd": 39.99,
        "discount_percentage": 25.0,
        "category": "electronics",
        "subcategory": "accessories",
        "tags": ["test", "sample"],
        "dimensions": {"width_cm": 10.0, "height_cm": 5.0, "depth_cm": 2.0, "weight_kg": 0.3},
        "in_stock": True,
        "stock_count": 100,
        "rating": 4.5,
        "review_count": 42,
        "features": ["Feature 1", "Feature 2"],
        "warnings": []
    }
    
    product = ProductListing(**sample_data)
    assert product.sku == "PROD-001"
    assert product.category == "electronics"

# Test 4: Integration test with mocked OpenAI client
@patch("openai.OpenAI")
def test_entity_extraction_integration(mock_openai_class):
    """Integration test with mocked API response"""
    from schemas import EntityExtractionResult, ExtractedEntity
    
    mock_result = EntityExtractionResult(
        entities=[
            ExtractedEntity(
                name="Apple", 
                entity_type="organization",
                confidence=0.98,
                context="Apple announced the new iPhone"
            )
        ],
        total_count=1,
        processing_notes="Extracted successfully"
    )
    
    mock_message = MagicMock()
    mock_message.parsed = mock_result
    mock_message.refusal = None
    
    mock_choice = MagicMock()
    mock_choice.message = mock_message
    mock_choice.finish_reason = "stop"
    
    mock_completion = MagicMock()
    mock_completion.choices = [mock_choice]
    
    mock_client = MagicMock()
    mock_client.beta.chat.completions.parse.return_value = mock_completion
    mock_openai_class.return_value = mock_client
    
    # Your function under test
    from extraction import extract_entities
    result = extract_entities("Apple announced the new iPhone")
    
    assert result.total_count == 1
    assert result.entities[0].name == "Apple"
    assert result.entities[0].confidence > 0.9

# Test 5: Regression test - schema version compatibility
def test_schema_backwards_compatibility():
    """Ensure schema changes don't break existing saved outputs"""
    import os
    regression_dir = "tests/regression_fixtures"
    
    if not os.path.exists(regression_dir):
        pytest.skip("No regression fixtures found")
    
    for fixture_file in os.listdir(regression_dir):
        if fixture_file.endswith(".json"):
            with open(os.path.join(regression_dir, fixture_file)) as f:
                fixture_data = json.load(f)
            
            schema_name = fixture_data.get("schema_name")
            sample_output = fixture_data.get("sample_output")
            
            # Import and validate against current schema
            from schemas import schema_registry
            if schema_name in schema_registry:
                schema_class = schema_registry[schema_name]
                schema_class(**sample_output)  # Should not raise

13. Framework Integration: Python, Node.js, TypeScript

Building Production AI Pipelines with LangChain and OpenAI

FastAPI Integration (Python)

# app/api/extraction.py
from fastapi import FastAPI, HTTPException, BackgroundTasks
from fastapi.responses import JSONResponse
from pydantic import BaseModel
from openai import OpenAI
import asyncio

app = FastAPI(title="Structured Extraction API")
client = OpenAI()

class ExtractionRequest(BaseModel):
    text: str
    extraction_type: str  # "entities", "sentiment", "products", etc.
    
class ExtractionResponse(BaseModel):
    success: bool
    data: dict
    tokens_used: int
    model: str

# Schema registry - map extraction types to Pydantic models
from schemas import (
    EntityExtractionResult,
    SentimentAnalysis,
    ProductListing
)

SCHEMA_REGISTRY = {
    "entities": EntityExtractionResult,
    "sentiment": SentimentAnalysis,
    "products": ProductListing
}

@app.post("/extract", response_model=ExtractionResponse)
async def extract_structured_data(request: ExtractionRequest):
    if request.extraction_type not in SCHEMA_REGISTRY:
        raise HTTPException(
            status_code=400,
            detail=f"Unknown extraction type. Valid: {list(SCHEMA_REGISTRY.keys())}"
        )
    
    schema_class = SCHEMA_REGISTRY[request.extraction_type]
    
    try:
        # Run synchronous OpenAI call in thread pool
        loop = asyncio.get_event_loop()
        completion = await loop.run_in_executor(
            None,
            lambda: client.beta.chat.completions.parse(
                model="gpt-4o-2024-08-06",
                messages=[
                    {"role": "system", "content": f"Extract structured {request.extraction_type} data."},
                    {"role": "user", "content": request.text}
                ],
                response_format=schema_class,
                temperature=0.1
            )
        )
        
        message = completion.choices[0].message
        
        if message.refusal:
            raise HTTPException(status_code=422, detail=f"Model refusal: {message.refusal}")
        
        return ExtractionResponse(
            success=True,
            data=message.parsed.model_dump(),
            tokens_used=completion.usage.total_tokens,
            model=completion.model
        )
        
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

Express.js / TypeScript Integration

// src/routes/extraction.ts
import express, { Request, Response } from "express";
import OpenAI from "openai";
import { z } from "zod";
import zodToJsonSchema from "zod-to-json-schema";

const router = express.Router();
const client = new OpenAI();

// Request validation
const ExtractionRequestSchema = z.object({
  text: z.string().min(10).max(50000),
  schema_name: z.enum(["entities", "sentiment", "products", "resume"]),
  temperature: z.number().min(0).max(2).default(0.1)
});

// Dynamic schema registry
import { entitySchema, sentimentSchema, productSchema } from "../schemas";

const SCHEMA_REGISTRY: Record = {
  entities: zodToJsonSchema(entitySchema, { $refStrategy: "none" }),
  sentiment: zodToJsonSchema(sentimentSchema, { $refStrategy: "none" }),
  products: zodToJsonSchema(productSchema, { $refStrategy: "none" })
};

router.post("/extract", async (req: Request, res: Response) => {
  const parseResult = ExtractionRequestSchema.safeParse(req.body);
  
  if (!parseResult.success) {
    return res.status(400).json({
      error: "Invalid request",
      details: parseResult.error.flatten()
    });
  }
  
  const { text, schema_name, temperature } = parseResult.data;
  const targetSchema = SCHEMA_REGISTRY[schema_name];
  
  try {
    const startTime = Date.now();
    
    const completion = await client.chat.completions.create({
      model: "gpt-4o-2024-08-06",
      messages: [
        {
          role: "system",
          content: `Extract structured ${schema_name} data from the provided text.`
        },
        { role: "user", content: text }
      ],
      response_format: {
        type: "json_schema",
        json_schema: {
          name: schema_name,
          strict: true,
          schema: targetSchema as Record<string, unknown>
        }
      },
      temperature
    });
    
    const latencyMs = Date.now() - startTime;
    const content = completion.choices[0].message.content;
    
    if (!content) {
      return res.status(422).json({ error: "Empty response from model" });
    }
    
    const parsed = JSON.parse(content);
    
    res.json({
      success: true,
      data: parsed,
      metadata: {
        model: completion.model,
        tokens_used: completion.usage?.total_tokens,
        latency_ms: latencyMs,
        finish_reason: completion.choices[0].finish_reason
      }
    });
    
  } catch (error) {
    console.error("Extraction error:", error);
    res.status(500).json({
      error: "Extraction failed",
      message: error instanceof Error ? error.message : "Unknown error"
    });
  }
});

export default router;

Next.js API Route Integration

// app/api/analyze/route.ts (Next.js App Router)
import { NextRequest, NextResponse } from "next/server";
import OpenAI from "openai";

const client = new OpenAI();

// Edge-compatible schema definition
const articleAnalysisSchema = {
  type: "object" as const,
  properties: {
    title: { type: "string" },
    summary: { type: "string" },
    sentiment: { type: "string", enum: ["positive", "negative", "neutral", "mixed"] },
    topics: { type: "array", items: { type: "string" } },
    reading_time_minutes: { type: "integer" },
    target_audience: { type: "string" },
    seo_keywords: { type: "array", items: { type: "string" } }
  },
  required: [
    "title", "summary", "sentiment", "topics",
    "reading_time_minutes", "target_audience", "seo_keywords"
  ],
  additionalProperties: false
};

export async function POST(request: NextRequest) {
  try {
    const { content } = await request.json();
    
    if (!content || typeof content !== "string") {
      return NextResponse.json({ error: "content is required" }, { status: 400 });
    }
    
    const completion = await client.chat.completions.create({
      model: "gpt-4o-mini",  // Cost-optimized for high-volume
      messages: [
        {
          role: "system",
          content: "Analyze the provided article and extract structured metadata."
        },
        { role: "user", content }
      ],
      response_format: {
        type: "json_schema",
        json_schema: {
          name: "article_analysis",
          strict: true,
          schema: articleAnalysisSchema
        }
      },
      temperature: 0
    });
    
    const result = JSON.parse(completion.choices[0].message.content!);
    
    return NextResponse.json({
      analysis: result,
      tokens: completion.usage?.total_tokens
    });
    
  } catch (error) {
    return NextResponse.json(
      { error: "Analysis failed" },
      { status: 500 }
    );
  }
}

export const runtime = "nodejs"; // Use "edge" with caution due to OpenAI SDK requirements

GPT-4o API Best Practices for Production Applications

14. Conclusion

Structured Outputs represents the most significant reliability improvement for

Get Free Access to 40,000+ AI Prompts for ChatGPT, Claude & Codex

Subscribe for instant access to the largest curated Notion Prompt Library for AI workflows.

More on this