L2 · 02 · Structured Output & JSON Schema

When code consumes model output, free text is a liability. This page shows how to get typed, validated data out of a model and into Scala case classes and Python models, with a repair loop for the cases where it still goes wrong.

Level 255 minTracks 3, 10Last verified 2026-10

By the end of this page you can

The problem

A customer emails the store: "Hi, order ORD-482913. The left earbud from the headphones is dead and I'd also like to send back the phone case, wrong colour. Refund please. I can drop them at the Riverside store." Your returns system needs this:

{
  "orderId": "ORD-482913",
  "items": [
    { "sku": "HEADPHONES", "quantity": 1, "reason": "DAMAGED" },
    { "sku": "PHONE_CASE", "quantity": 1, "reason": "WRONG_ITEM" }
  ],
  "wantsRefund": true,
  "storeId": "RIVERSIDE"
}

A model answering in prose ("Sure! Here's the extracted info: …") breaks your parser. Even "JSON only" prompts sometimes add code fences, comments, trailing commas, or a different field name. You need guarantees.

Four levels of strictness

LevelHowGuaranteeUse when
1. Ask nicely"Return only JSON like this example…"None. Usually works, sometimes doesn't.Prototypes, local models without schema support
2. JSON modeAPI flag like response_format: {"type": "json_object"}Valid JSON syntax, but any shapeSimple cases
3. Schema-enforcedProvide a JSON Schema; the provider constrains decoding to itValid JSON matching the schema's structureProduction extraction and classification
4. Tool argumentsDefine a "tool" whose parameters are your schema (page 04)Same as 3, plus the model chooses whether to callAgents, or when the model decides what to do
How enforcement works With constrained decoding, at each step the server only allows tokens that keep the output valid against the schema (or grammar). The model literally cannot produce a missing brace or an unknown field. Support and exact parameter names vary by provider and local runner, so check yours. The concepts are the same everywhere.

Validation is still your job

Schema enforcement guarantees shape, not truth. The model can still return a well-formed but invented order ID, a wrong reason, or a quantity of 0. Always run domain validation in code.

Prompt + schema LLM 1. Parse JSONdecode to types 2. Validatebusiness rules Typed value errors → send back as feedback → retry (max 2–3), then escalate to a human

Designing schemas models get right

{
  "type": "object",
  "additionalProperties": false,
  "required": ["orderId", "items", "wantsRefund", "storeId"],
  "properties": {
    "orderId":     { "type": ["string", "null"], "description": "Order ID in format ORD-123456. null if not stated. Never guess." },
    "items": {
      "type": "array",
      "description": "Items the customer wants to return",
      "items": {
        "type": "object",
        "additionalProperties": false,
        "required": ["sku", "quantity", "reason"],
        "properties": {
          "sku":      { "type": "string",  "description": "Product code from the catalogue list provided" },
          "quantity": { "type": "integer", "description": "Number of units, at least 1" },
          "reason":   { "type": "string",  "enum": ["DAMAGED", "WRONG_ITEM", "NOT_AS_DESCRIBED", "CHANGED_MIND", "OTHER"] }
        }
      }
    },
    "wantsRefund": { "type": "boolean", "description": "true for refund, false for exchange/store credit" },
    "storeId":     { "type": ["string", "null"], "description": "Store ID from the store list if the customer named one, else null" }
  }
}
Strict-mode limitations Providers that strictly enforce schemas often support only a subset of JSON Schema. Common requirements are that every property is listed in required (so optional means nullable), additionalProperties: false, and limited support for pattern, minimum or format. That is one more reason to validate formats and ranges in your code.

The request (OpenAI-compatible shape)

{
  "model": "<model>",
  "temperature": 0,
  "messages": [
    { "role": "system", "content": "Extract return requests from customer emails. Valid SKUs: HEADPHONES, PHONE_CASE, CHARGER, TABLET. Valid stores: RIVERSIDE, CENTRAL, AIRPORT." },
    { "role": "user",   "content": "<email>Hi, order ORD-482913 ...</email>" }
  ],
  "response_format": {
    "type": "json_schema",
    "json_schema": { "name": "return_request", "strict": true, "schema": { "...": "the schema above" } }
  }
}

Notice the allowed values in the system prompt: the model maps "phone case, wrong colour" to PHONE_CASE / WRONG_ITEM only because you told it the valid codes. Page 03 sends this request from real code.

Scala 3: decode, validate, retry

This example runs offline: it feeds sample "model outputs" through the same pipeline you would use in production.

//> using scala 3
//> using dep io.circe::circe-core:0.14.15
//> using dep io.circe::circe-parser:0.14.15
import io.circe.{Decoder, Encoder}
import io.circe.parser.decode
import io.circe.syntax.*

enum ReturnReason:
  case DAMAGED, WRONG_ITEM, NOT_AS_DESCRIBED, CHANGED_MIND, OTHER

object ReturnReason:
  given Decoder[ReturnReason] = Decoder.decodeString.emap: s =>
    values.find(_.toString == s).toRight(s"unknown reason '$s'")
  given Encoder[ReturnReason] = Encoder.encodeString.contramap(_.toString)

final case class ReturnItem(sku: String, quantity: Int, reason: ReturnReason) derives Decoder, Encoder.AsObject
final case class ReturnRequest(
    orderId: Option[String],
    items: List[ReturnItem],
    wantsRefund: Boolean,
    storeId: Option[String]
) derives Decoder, Encoder.AsObject

val KnownSkus   = Set("HEADPHONES", "PHONE_CASE", "CHARGER", "TABLET")
val KnownStores = Set("RIVERSIDE", "CENTRAL", "AIRPORT")
val OrderIdFmt  = """ORD-\d{6}""".r

/** Business rules the schema cannot express. Returns ALL problems, not just the first. */
def validate(r: ReturnRequest): Either[List[String], ReturnRequest] =
  val errors =
    r.orderId.filterNot(OrderIdFmt.matches).map(id => s"orderId '$id' must match ORD-123456").toList ++
    Option.when(r.items.isEmpty)("items must not be empty").toList ++
    r.items.filter(_.quantity < 1).map(i => s"quantity for ${i.sku} must be >= 1") ++
    r.items.filterNot(i => KnownSkus(i.sku)).map(i => s"unknown sku '${i.sku}'") ++
    r.storeId.filterNot(KnownStores).map(s => s"unknown storeId '$s'").toList
  if errors.isEmpty then Right(r) else Left(errors)

def parse(raw: String): Either[List[String], ReturnRequest] =
  decode[ReturnRequest](raw).left.map(e => List(s"invalid JSON for schema: ${e.getMessage}")).flatMap(validate)

/** ask(feedback) calls the model; feedback is None on the first attempt. */
def extractWithRetry(ask: Option[String] => String, maxAttempts: Int = 3): Either[List[String], ReturnRequest] =
  def loop(attempt: Int, feedback: Option[String]): Either[List[String], ReturnRequest] =
    parse(ask(feedback)) match
      case ok @ Right(_)                     => ok
      case Left(errs) if attempt < maxAttempts =>
        loop(attempt + 1, Some(s"Your previous answer had these problems:\n- ${errs.mkString("\n- ")}\nFix them and answer again."))
      case failed                            => failed // escalate to a human
  loop(1, None)

@main def demo(): Unit =
  // Simulated model: first answer has a bad SKU and quantity, second is corrected
  val answers = Iterator(
    """{"orderId":"ORD-482913","items":[{"sku":"EARBUDS","quantity":0,"reason":"DAMAGED"}],"wantsRefund":true,"storeId":"RIVERSIDE"}""",
    """{"orderId":"ORD-482913","items":[{"sku":"HEADPHONES","quantity":1,"reason":"DAMAGED"},{"sku":"PHONE_CASE","quantity":1,"reason":"WRONG_ITEM"}],"wantsRefund":true,"storeId":"RIVERSIDE"}"""
  )
  val result = extractWithRetry { feedback =>
    feedback.foreach(f => println(s"--- feedback sent to model ---\n$f\n"))
    answers.next()
  }
  println(result.map(_.asJson.spaces2))

Points worth noticing:

Python: pydantic does both jobs

Pydantic v2 models give you validation and can generate the JSON Schema to send to the model, so there is one source of truth.

from enum import Enum
from typing import Callable
from pydantic import BaseModel, ConfigDict, Field, ValidationError, field_validator

KNOWN_SKUS = {"HEADPHONES", "PHONE_CASE", "CHARGER", "TABLET"}
KNOWN_STORES = {"RIVERSIDE", "CENTRAL", "AIRPORT"}


class ReturnReason(str, Enum):
    DAMAGED = "DAMAGED"
    WRONG_ITEM = "WRONG_ITEM"
    NOT_AS_DESCRIBED = "NOT_AS_DESCRIBED"
    CHANGED_MIND = "CHANGED_MIND"
    OTHER = "OTHER"


class ReturnItem(BaseModel):
    model_config = ConfigDict(extra="forbid")
    sku: str = Field(description="Product code from the catalogue list provided")
    quantity: int = Field(ge=1, description="Number of units, at least 1")
    reason: ReturnReason

    @field_validator("sku")
    @classmethod
    def known_sku(cls, v: str) -> str:
        if v not in KNOWN_SKUS:
            raise ValueError(f"unknown sku '{v}'")
        return v


class ReturnRequest(BaseModel):
    model_config = ConfigDict(extra="forbid")
    orderId: str | None = Field(pattern=r"^ORD-\d{6}$", description="ORD-123456, null if not stated")
    items: list[ReturnItem] = Field(min_length=1)
    wantsRefund: bool
    storeId: str | None

    @field_validator("storeId")
    @classmethod
    def known_store(cls, v: str | None) -> str | None:
        if v is not None and v not in KNOWN_STORES:
            raise ValueError(f"unknown storeId '{v}'")
        return v


SCHEMA = ReturnRequest.model_json_schema()   # send this in response_format


def extract_with_retry(ask: Callable[[str | None], str], max_attempts: int = 3) -> ReturnRequest:
    feedback = None
    for attempt in range(1, max_attempts + 1):
        raw = ask(feedback)
        try:
            return ReturnRequest.model_validate_json(raw)
        except ValidationError as e:
            problems = [f"{'.'.join(map(str, err['loc']))}: {err['msg']}" for err in e.errors()]
            feedback = "Your previous answer had these problems:\n- " + "\n- ".join(problems) + "\nFix them and answer again."
    raise RuntimeError(f"extraction failed after {max_attempts} attempts; escalate to a human")


if __name__ == "__main__":
    answers = iter([
        '{"orderId":"ORD-482913","items":[{"sku":"EARBUDS","quantity":0,"reason":"DAMAGED"}],"wantsRefund":true,"storeId":"RIVERSIDE"}',
        '{"orderId":"ORD-482913","items":[{"sku":"HEADPHONES","quantity":1,"reason":"DAMAGED"}],"wantsRefund":true,"storeId":"RIVERSIDE"}',
    ])

    def fake_ask(feedback: str | None) -> str:
        if feedback:
            print("--- feedback ---\n" + feedback + "\n")
        return next(answers)

    print(extract_with_retry(fake_ask).model_dump_json(indent=2))
Scala equivalent of "schema from types" In Scala you can hand-write the schema (as above, simple and explicit) or generate it from case classes with a JSON Schema library. Tapir, for example, can derive schemas from your types. Hand-written is fine for a handful of schemas. Generate when you have many, so schema and types cannot drift apart.

Common failure modes

SymptomCauseFix
JSON cut off mid-objectHit max output tokensRaise the limit; detect finish_reason == "length" and retry
Wrapped in ```json fencesNo JSON mode (level 1)Use JSON/schema mode, or strip fences before parsing
Invented IDs or SKUsField required but data absentMake it nullable, say "never guess", validate against known values
Correct shape, wrong categoryAmbiguous enum meaningDescribe each enum value; add few-shot examples
Refusal text instead of JSONSafety refusalHandle the provider's refusal field/finish reason explicitly
Numbers as strings, odd datesLoose schemaUse proper types and an explicit format description, then parse dates in code
Try it (30 min)
  1. Run ReturnExtraction.scala (or the Python version). Read the feedback message it prints.
  2. Add a new rule: returns with reason CHANGED_MIND cannot be refunds (store credit only). Implement it in validation.
  3. With a real model (page 03 shows how), extract from 5 messy emails you write yourself, including one with no order ID. Does it return null or guess?

Key takeaways