TechEarl

How to Get Reliable JSON from an LLM

Get reliable JSON out of an LLM with native structured-output modes (Anthropic tool use, OpenAI Structured Outputs, Gemini schema), plus Zod / Pydantic validation as a fallback.

Ishan KarunaratneIshan Karunaratne⏱️ 6 min readUpdated
Get reliable JSON from an LLM with structured-output modes on Anthropic, OpenAI, Gemini. Plus Zod/Pydantic validation, retry strategies, and common pitfalls.

Three reliable ways to make an LLM return JSON that parses every time: use the provider's native structured-output mode (Anthropic tool use, OpenAI Structured Outputs, Gemini's response_schema), validate against a schema with Zod or Pydantic after parsing, and retry with the error message if validation fails. In 2026 the structured-output modes are mature enough that a well-written schema produces valid JSON on the first call essentially 100% of the time — the retry path is a defensive backstop, not the main flow. I'll walk all three with runnable code in JavaScript and Python, plus the prompt-engineering tricks for cases where you cannot use a structured-output mode.

The reason "JSON from an LLM" used to be hard: free-form generation occasionally produces trailing commas, smart quotes, comments, or extra prose before/after the JSON. In 2024 you'd see "JSON mode" in OpenAI as a coarse flag that guaranteed valid JSON but not the right shape. In 2025, every major provider added schema-constrained JSON that guarantees both validity and shape. That's the path to use.

Jump to:

Anthropic: tool use as structured output

Anthropic's pattern: define a single tool with the schema you want, force the model to call it, then pull the JSON out of the tool-use block. Tool use was designed for function calling but works equally well as a structured-output mechanism.

JavaScript:

javascript
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic();

const response = await client.messages.create({
  model: "claude-sonnet-4-6",
  max_tokens: 1024,
  tool_choice: { type: "tool", name: "extract_invoice" },
  tools: [{
    name: "extract_invoice",
    description: "Extract structured invoice data from the input",
    input_schema: {
      type: "object",
      properties: {
        invoice_number: { type: "string" },
        total_cents: { type: "integer" },
        line_items: {
          type: "array",
          items: {
            type: "object",
            properties: {
              description: { type: "string" },
              amount_cents: { type: "integer" },
            },
            required: ["description", "amount_cents"],
          },
        },
      },
      required: ["invoice_number", "total_cents", "line_items"],
    },
  }],
  messages: [{ role: "user", content: "Invoice #INV-4521..." }],
});

const result = response.content.find((b) => b.type === "tool_use")?.input;

The tool_choice: { type: "tool", name: "..." } forces the model to use that specific tool — it cannot reply with prose, refuse, or pick a different action. The output is guaranteed to match the schema or the call fails with an error.

OpenAI: Structured Outputs with a JSON Schema

OpenAI's Structured Outputs is the direct equivalent — pass a JSON Schema and set strict: true to get back exactly that shape.

javascript
import OpenAI from "openai";
const client = new OpenAI();

const response = await client.chat.completions.create({
  model: "gpt-5",
  messages: [{ role: "user", content: "Invoice #INV-4521..." }],
  response_format: {
    type: "json_schema",
    json_schema: {
      name: "invoice",
      strict: true,
      schema: {
        type: "object",
        properties: {
          invoice_number: { type: "string" },
          total_cents: { type: "integer" },
          line_items: {
            type: "array",
            items: {
              type: "object",
              properties: {
                description: { type: "string" },
                amount_cents: { type: "integer" },
              },
              required: ["description", "amount_cents"],
              additionalProperties: false,
            },
          },
        },
        required: ["invoice_number", "total_cents", "line_items"],
        additionalProperties: false,
      },
    },
  },
});

const result = JSON.parse(response.choices[0].message.content);

additionalProperties: false on every object is mandatory for strict mode — it tells the API "no extra fields, ever". required arrays must list every property defined (OpenAI does not support optional fields in strict mode, but you can simulate them with ["string", "null"] union types).

Gemini: response_schema parameter

Gemini accepts a schema directly via response_schema. The shape mirrors a Pydantic model or a TypeScript type.

python
from google import genai
from pydantic import BaseModel

class LineItem(BaseModel):
    description: str
    amount_cents: int

class Invoice(BaseModel):
    invoice_number: str
    total_cents: int
    line_items: list[LineItem]

client = genai.Client()
response = client.models.generate_content(
    model="gemini-2.5-pro",
    contents="Invoice #INV-4521...",
    config={
        "response_mime_type": "application/json",
        "response_schema": Invoice,
    },
)
invoice = Invoice.model_validate_json(response.text)

The Pydantic model serves as both the schema for Gemini AND the parser on the way back. One source of truth.

Validate the result anyway: Zod and Pydantic

Native structured output is reliable but not infallible. Use a runtime validator on the result to catch the rare edge case and to give you a typed object instead of an any:

JavaScript with Zod:

javascript
import { z } from "zod";

const InvoiceSchema = z.object({
  invoice_number: z.string(),
  total_cents: z.number().int().nonnegative(),
  line_items: z.array(
    z.object({
      description: z.string(),
      amount_cents: z.number().int(),
    })
  ),
});

const invoice = InvoiceSchema.parse(rawJsonFromLlm);  // throws ZodError if invalid

Python with Pydantic:

python
from pydantic import BaseModel, Field, ValidationError

class Invoice(BaseModel):
    invoice_number: str
    total_cents: int = Field(ge=0)
    line_items: list[LineItem]

try:
    invoice = Invoice.model_validate_json(raw_json)
except ValidationError as e:
    # Handle the validation error — retry, log, surface to user
    ...

The validator catches semantic constraints the schema cannot (a total_cents that is suspiciously larger than the sum of line-item amounts, a date in the future, an email that doesn't pass the email regex pattern).

The retry-with-error pattern

When validation fails, retry once with the error message appended to the prompt. The model self-corrects more often than not.

javascript
async function getValidatedInvoice(input) {
  for (let attempt = 1; attempt <= 3; attempt++) {
    const raw = await callLLM(input);
    try {
      return InvoiceSchema.parse(JSON.parse(raw));
    } catch (err) {
      if (attempt === 3) throw err;
      input += `\n\nPrevious response had this validation error:\n${err.message}\nReturn corrected JSON only.`;
    }
  }
}

Three attempts is the right ceiling. After three the model is unlikely to fix itself; escalate to a different model tier (Sonnet → Opus) or fail loudly.

What to do when structured output isn't available

For older models, open-source models without schema mode, or providers that don't support it, the fallback pattern:

  1. Explicit format instruction in the system prompt: "Return your answer as a single JSON object. No markdown fences, no prose, no comments. The JSON object must have exactly these keys: ..."
  2. Few-shot examples showing the exact output format you want.
  3. Strip wrapping whitespace and markdown fences before parsing: raw.trim().replace(/^```(?:json)?\s*|\s*```$/g, "").
  4. Validate with Zod / Pydantic and retry-with-error.

This is the "old way" — works fine for one-off scripts, not great for production where you want zero parsing errors. Move to a structured-output mode whenever the provider supports it.

What to do next

For the LLM-API foundation that lets all this scale:

For the post-extraction validation layer:

  • The Regex Cheat Sheet covers the pattern syntax for field-level validation after parse (email, URL, dates, IPs).

External references: Anthropic tool use documentation, OpenAI Structured Outputs, Gemini structured output.

FAQ

TagsLLMJSONStructured OutputsAnthropicOpenAIGeminiZodPydantic
Share
Ishan Karunaratne

Ishan Karunaratne

Tech Architect · Software Engineer · AI/DevOps

Tech architect and software engineer with 20+ years across software, Linux systems, DevOps, and infrastructure — and a more recent focus on AI. Currently Chief Technology Officer at a tech startup in the healthcare space.

Keep reading

Related posts

Macro photograph of a stack of paper documents on a dark slate desk with a single sheet pulled out and crumpled to the side, warm amber side light

How to Delete Duplicate Rows in MySQL

Delete duplicate rows in MySQL while keeping one per group, using DELETE JOIN, ROW_NUMBER with CTE, or the safe temp-table swap. With dry-run, transactions, and rollback.

Six techniques that actually reduce LLM hallucination: grounding, citations, tool use, structured outputs, explicit don't-know, and LLM-as-judge verification.

How to Stop an LLM from Hallucinating

Six techniques that actually reduce LLM hallucination: grounding with retrieved context, citation requirements, tool use for facts, structured outputs, explicit don't-know permission, and LLM-as-judge verification.

Remove empty, null, false, or empty-string values from a PHP array. Covers array_filter, the '0 gets removed' gotcha, array_values re-indexing, multidimensional cleanup, and a performance comparison.

How to Remove Empty Values from an Array in PHP

Drop empty, null, or false values from a PHP array with array_filter and the right callback. Includes the '0 gets removed' gotcha, the array_values re-index pattern, multidimensional cleanup, and a performance comparison.