Skip to main content
Lesson 11 of 16 · Prompt Engineering for Developers

Validation with JSON Schema and Zod

Lesson 11 of 16 Updated November 3, 2026 Developer
On this page
  1. Validation with JSON Schema and Zod
  2. Why validate after receipt
  3. Python validation with jsonschema
  4. Validation with Pydantic
  5. Validation with Zod (TypeScript/Node.js)
  6. Pattern: retry with correction
  7. A test bench for calendar invitations
  8. Key takeaways

Lesson updated November 3, 2026 based on OpenAI documentation: temperature and top_p are only accepted with a reasoning effort of none, whereas GPT-5.6 models reason by default (medium). Calls that set these parameters must therefore specify reasoning={"effort": "none"} (Responses API) or reasoning_effort="none" (Chat Completions). Sources: Using GPT-6 · GPT-5.6 Terra.

Validation with JSON Schema and Zod

Structured Output guarantees the response structure, but not the semantic validity of the data. An “email” field might contain a string that isn’t a valid email address. Client-side validation using JSON Schema or Zod is the essential complement for a robust pipeline.

Why validate after receipt

Take stock of what the API guarantees, and the division of labor becomes clear. OpenAI’s Structured Output ensures the JSON is syntactically valid, that declared types are respected—a string remains a string, an integer remains an integer—that all required fields are present, and that values constrained by an enum belong to the list. On these four points, you can write your code with confidence.

Everything else is on you, covering four families of problems. Format constraints escape the strict schema: nothing stops the model from returning jean.dupont(at)example.fr in an email field, or a date in 15/03/2026 format when you expect ISO. Bounds aren’t checked either, neither for string length nor number range: an age of 350 will pass without blinking. Cross-field consistency remains out of reach—a end_date earlier than start_date is perfectly valid JSON. And business validity has no reason to concern the API: a SIRET (French business ID) must be fourteen digits, but that’s your domain’s knowledge, not the decoder’s.

These four families share a daunting trait: they cause no visible errors. A malformed email saved to the database won’t manifest until the email campaign bounces weeks later. Hence the rule: validate before persisting, never after.

Python validation with jsonschema

The practical consequence is that you need two schemas for the same data. The first goes to the API, intentionally lean, limited to what strict mode accepts. The second stays with you and carries the fine-grained constraints—format, pattern, minimum, maximum. This apparent duplication is a separation of concerns: one frames generation, the other defends your storage. In the example below, only the role enum appears in both, because it’s the only constraint both levels can express.

import json
from jsonschema import validate, ValidationError

# Schema for OpenAI API (strict, no format/pattern)
api_schema = {
    "type": "object",
    "properties": {
        "email": {"type": "string"},
        "age": {"type": "integer"},
        "role": {"type": "string", "enum": ["admin", "user", "viewer"]}
    },
    "required": ["email", "age", "role"],
    "additionalProperties": False
}

# Extended schema for client-side validation
validation_schema = {
    "type": "object",
    "properties": {
        "email": {
            "type": "string",
            "format": "email",
            "pattern": r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z]{2,}$"
        },
        "age": {
            "type": "integer",
            "minimum": 0,
            "maximum": 150
        },
        "role": {"type": "string", "enum": ["admin", "user", "viewer"]}
    },
    "required": ["email", "age", "role"]
}

def validate_response(data: dict) -> tuple[bool, str]:
    """Validates the LLM response against the extended schema."""
    try:
        validate(instance=data, schema=validation_schema)
        return True, "Valid"
    except ValidationError as e:
        return False, f"Error: {e.message}"

Validation with Pydantic

Pydantic is often more convenient than jsonschema for validation in Python, and the difference lies in what you get on output. jsonschema tells you yes or no and leaves you with a dictionary, manipulated via data["email"] with no editor assistance. Pydantic returns a typed object: autocompletion works, a typo in an attribute name is caught at write time, and the data circulates thereafter without conversion.

The ContactExtraction model expresses bounds directly in the field declarations. The field_validator on date_extraction covers the case no type annotation can catch: the string is indeed a string, but it must represent a real date—2026-02-31 satisfies any reasonable pattern yet doesn’t exist. The extraction function shows the full chain, from the lean schema sent to the API to the validated instantiation.

from pydantic import BaseModel, Field, field_validator
from pydantic import EmailStr
from datetime import date

class ContactExtraction(BaseModel):
    """Validation schema for contact extraction."""
    last_name: str = Field(min_length=2, max_length=100)
    first_name: str = Field(min_length=2, max_length=100)
    email: EmailStr
    phone: str = Field(pattern=r"^\+?[0-9\s\-\.]{10,15}$")
    company: str
    title: str
    extraction_date: str

    @field_validator("extraction_date")
    @classmethod
    def validate_date(cls, v):
        try:
            date.fromisoformat(v)
            return v
        except ValueError:
            raise ValueError(f"Invalid date: {v}")

def extract_and_validate(text: str) -> ContactExtraction:
    """Extracts a contact and validates the data."""
    # Schema for API (without fine-grained constraints)
    api_schema = {
        "type": "object",
        "properties": {
            "last_name": {"type": "string"},
            "first_name": {"type": "string"},
            "email": {"type": "string"},
            "phone": {"type": "string"},
            "company": {"type": "string"},
            "title": {"type": "string"},
            "extraction_date": {"type": "string"}
        },
        "required": ["last_name", "first_name", "email", "phone",
                      "company", "title", "extraction_date"],
        "additionalProperties": False
    }

    response = client.responses.create(
        model="gpt-5.6-terra",
        instructions="Extract contact information. "
                     "Date format: YYYY-MM-DD.",
        input=text,
        text={
            "format": {
                "type": "json_schema",
                "name": "contact",
                "schema": api_schema,
                "strict": True
            }
        }
    )

    data = json.loads(response.output_text)
    # Pydantic validation with fine-grained constraints
    return ContactExtraction(**data)

Validation with Zod (TypeScript/Node.js)

If your backend is in TypeScript, Zod is the reference tool, and it brings an advantage Pydantic doesn’t need to provide: z.infer derives the TypeScript type directly from the validation schema. You describe the shape once, and the compiler then knows the structure of Invoice without you maintaining a parallel interface, so there’s no risk of divergence.

The invoice schema below illustrates the division of labor: on the API side, siret is just a string; on the Zod side, it must be exactly fourteen characters and contain only digits. The final parse raises an exception if the data doesn’t fit, and that’s the desired behavior—a loud error that interrupts document processing is better than a fantasy SIRET invoice stored in the database among ten thousand others.

import { z } from "zod";
import OpenAI from "openai";

const client = new OpenAI();

// Zod schema with fine-grained validations
const InvoiceSchema = z.object({
  number: z.string().regex(/^[A-Z]{2}-\d{4}-\d{4}$/),
  date: z.string().date(),
  amount_excl_tax: z.number().positive(),
  vat_rate: z.number().min(0).max(100),
  amount_incl_tax: z.number().positive(),
  supplier: z.object({
    name: z.string().min(2),
    siret: z.string().length(14).regex(/^\d+$/),
  }),
});

type Invoice = z.infer<typeof InvoiceSchema>;

async function extractInvoice(text: string): Promise<Invoice> {
  const response = await client.responses.create({
    model: "gpt-5.6-terra",
    instructions: "Extract invoice data.",
    input: text,
    text: {
      format: {
        type: "json_schema",
        name: "invoice",
        schema: {
          type: "object",
          properties: {
            number: { type: "string" },
            date: { type: "string" },
            amount_excl_tax: { type: "number" },
            vat_rate: { type: "number" },
            amount_incl_tax: { type: "number" },
            supplier: {
              type: "object",
              properties: {
                name: { type: "string" },
                siret: { type: "string" },
              },
              required: ["name", "siret"],
              additionalProperties: false,
            },
          },
          required: ["number", "date", "amount_excl_tax",
                     "vat_rate", "amount_incl_tax", "supplier"],
          additionalProperties: false,
        },
        strict: true,
      },
    },
  });

  const data = JSON.parse(response.output_text);

  // Zod validation with fine-grained constraints
  return InvoiceSchema.parse(data);
}

Pattern: retry with correction

When validation fails, send the error back to the model for correction. This reflex changes the nature of failure: a date in the wrong format is no longer an exception bubbling up to the user, but an extra round trip of a few hundred milliseconds that no one notices. The mechanism is simple—we reinject the validation message into the next attempt’s prompt, so the model knows exactly what went wrong instead of guessing blindly a second time.

This comfort requires boundaries, however. Cap the number of attempts, or an impossible constraint—a pattern too strict for foreign numbers, for example—will cost you calls in a loop. And monitor the retry rate: if a quarter of your extractions go through a retry, the problem lies in the initial prompt or the schema, not the model.

def extract_with_retry(prompt: str, schema: dict,
                       validator, max_retries: int = 3) -> dict:
    """Extracts and validates with automatic retry."""
    last_error = None

    for attempt in range(max_retries):
        input_text = prompt
        if last_error:
            input_text += (f"\n\nWARNING: Your previous response had "
                          f"this validation error: {last_error}\n"
                          f"Correct and try again.")

        response = client.responses.create(
            model="gpt-5.6-terra",
            reasoning={"effort": "none"},  # temperature requires effort none
            input=input_text,
            text={"format": {"type": "json_schema",
                             "name": "data",
                             "schema": schema,
                             "strict": True}},
            temperature=0.1
        )

        data = json.loads(response.output_text)
        is_valid, error = validator(data)

        if is_valid:
            return data
        last_error = error

    raise ValueError(f"Failed after {max_retries} attempts: {last_error}")

A test bench for calendar invitations

Build a Pydantic schema to extract events—title, date, time, location, participants—then plug it behind a Structured Output call. Test on five emails containing invitations, making sure to include at least one where the time is written out (“Thursday late morning”) and one where the location is implicit (“as usual in the boardroom”). These are the cases that break validation, and they dominate in a real inbox.

Then add automatic retry and measure two figures: the first-try success rate and the post-retry rate. The second tells you what correction buys you, the first whether you should rework your instructions rather than count on a second chance.

Key takeaways

  • Structured Output guarantees structure, not semantic validity
  • Use two schemas: one for the API (strict), one for client validation
  • Pydantic (Python) and Zod (TypeScript) are the reference tools
  • The retry-with-feedback pattern corrects most validation errors
  • Always validate client-side before persisting data