How to test LLM JSON output against a schema

John Ennis · · 4 min read

Problem

Downstream code expects valid JSON with the right fields. One model or prompt change and parsing fails in production.

Outcome

Every case asserts the output validates against a per-case JSON schema, and optionally checks exact field values.

To test LLM JSON output, give each test case a JSON schema and fail the build when the model's response doesn't parse or doesn't validate. Drift-CI's json-schema evaluator does exactly that on every pull request: invalid JSON or a schema violation scores 0, and a drop from a passing baseline fails the check.

Why structured output breaks

Classifiers, extractors and tool-calling prompts all hand their output to code. That code wants a specific shape: an object, these keys, these enum values, a number between 0 and 1. The model doesn't know that unless the prompt says so, and it keeps following the prompt only as long as nothing changes.

Plenty does change. A prompt edit adds a field or reorders instructions. A model swap brings a model that likes to wrap JSON in a markdown fence or add "Here is the classification:" first. A new enum value appears that your code has never seen. Each of these turns into a parse error or a wrong branch in production, usually on a fraction of inputs, which is why spot-checking misses it.

Test LLM JSON output with the json-schema evaluator

Add json-schema to a case's evaluators and put the schema next to it. This case is from our dogfood suite, which tests a support-ticket triage prompt:

- id: classify-structured-billing
  tags: [classification, json-schema]
  systemPrompt: |
    Classify the support ticket. Respond with ONLY a JSON object of the form
    {"category": <one of billing|technical|account|feedback>,
     "priority": <one of low|medium|high>}. No prose, no code fences.
  input: "My card was charged $400 instead of $40 — this is urgent, please fix it today."
  evaluators: [json-schema]
  schema:
    type: object
    required: [category, priority]
    properties:
      category:
        type: string
        enum: [billing, technical, account, feedback]
      priority:
        type: string
        enum: [low, medium, high]
    additionalProperties: false

The evaluator parses the raw response and validates it with Ajv. It's binary:

  • the response isn't valid JSON: score 0, reason "Output is not valid JSON";
  • it parses but breaks the schema: score 0, with every violation listed (for example a missing required property or a value outside the enum);
  • it validates: score 1.

Two details matter. First, the schema is per case, so a suite can cover several output shapes. Second, a case that lists json-schema without a schema is rejected when the suite loads, so you can't ship a check that silently checks nothing.

additionalProperties: false is worth keeping. It catches the model adding fields you didn't ask for, which is often the first sign that it's reading the instructions differently.

Combining a schema with exact-match

A schema tells you the shape is right, not that the answer is. To pin the answer as well, add exact-match to the same case's evaluator list and set expected to the exact response you want. The case score is then a weighted combination of the two; with no weights given, each evaluator counts equally.

Be precise about what exact-match compares. It checks the whole response against expected, after trimming whitespace and lowercasing both. It doesn't compare individual fields, so a response with different spacing or key order won't match even if the values are identical. That works well for short, tightly specified outputs. For anything looser, keep the schema check and use an LLM judge or a rubric for the content.

The Drift-CI repo's classification example shows several schemas for one intent classifier, including an array field with minItems.

Evaluator errors versus regressions

Drift-CI draws a line between "the output was bad" and "we couldn't score the output":

  • Invalid or non-conforming JSON is a score, and a low one. If the baseline scored 1 and this run scores 0, the case is a regression and the check fails.
  • An evaluator error is a case Drift-CI couldn't score at all: for example an LLM judge that failed, or a request the provider rejected. Those cases get no score, show an orange marker in the PR comment, and are excluded from delta math, so they never count as regressions.
  • Transient provider failures are never regressions either; the guide's section on flakiness and outages covers them.

This matters for structured output in particular. When a model switch starts wrapping JSON in a code fence, you want a red check, not a vague error. In our own dogfood runs, trying a cheaper model for the triage agent produced exactly that kind of red check: both structured classification cases dropped from 1 to 0 against their baselines and failed the PR, while the plain-label classification cases passed.

Running it in CI

Run the suite on every pull request with the Drift-CI GitHub Action; the GitHub Actions tutorial walks through the workflow line by line.

Once a case passes, accept its baseline and commit it. From then on, any prompt or model change that breaks the shape shows up in review. For the wider workflow around baselines and thresholds, see prompt regression testing. If you're about to switch models, read testing an LLM model upgrade first; structured cases are where our model swaps broke. The LLM regression testing guide covers how schema checks fit alongside judges and similarity scoring.

FAQ

Does the json-schema evaluator accept JSON inside a code fence?
No. It parses the raw response with JSON.parse, so a response wrapped in a markdown code fence or preceded by prose scores 0 as invalid JSON. That is deliberate: if your application parses the raw response, the test should too. Tell the model to return only the JSON object, with no prose and no code fences.
Which JSON Schema features are supported?
Drift-CI compiles each case's schema with Ajv, with format validation enabled, so the usual keywords work: type, required, properties, enum, minimum and maximum, items, minItems, additionalProperties and string formats. The schema is written in YAML inside the case.

Catch LLM regressions before they ship

Run your evals on every pull request and fail the build when behaviour drifts past your threshold.