Skip to content

Define a schema

  • Lesson 8
  • 50 min
  • Module 3: Structured outputs and validation

What you will be able to do

Turn the assistant’s answer into a contract that code can check. Then a wrong team name, a missing field or an order ID in the wrong format never travels further into TermLarkfieldAn online shop for the home. It is not a real company. All Nextia courses use its help desk and data as the example.Learn more AI in everyday productsAI Career Foundations: Roles, Skills and Roadmaps’s help desk.

Before you startWhat to know and to have readyAbout 50 minutes

What you need to know

What you need on your computer

  • The ticket-assistant project at the end of Module 2, in ~/projects/ticket-assistant, with its .venv active. python -m pytest shows 21 passed, 1 deselected.
  • If your project is broken or missing, copy the snapshot end-of-m02 with the steps of Reset the project.
  • The labs repository in ~/projects/nextia-learning-labs, from A first run without an account. Step 1 copies new recorded answers from it.

No account and no key: every model answer comes from the mock provider. Tested with Python 3.12.13 and Pydantic 2.14.0 on macOS (Apple silicon). The Windows commands are not tested.

Models and APIs change fast. The numbers in this module are a snapshot. The method is what lasts.

Think of a form at a post office counter. Each item has its own box. “Country” has a list: tick one. “Date of birth” has eight small squares, DD MM YYYY. Some boxes have a star: you must fill them in. The clerk checks a form in seconds, because every form has the same shape.

Now imagine that the post office accepts letters instead: “Hello, I would like to send this to my aunt in Lyon…”. Every letter says it differently. The clerk must read each one and guess.

Larkfield’s assistant is a program, and the code that reads its answers is the clerk.

In Module 2, the prompt asked for “one JSON object, no other text”, and the code tried json.loads on the answer. The prompt asked. Nothing checked. Here is the answer of TermGemma 3 4BA small open model from Google, with 4 billion parameters. It can run on a laptop with Ollama, without an account or a cost per call.Learn more Hosted and self-hosted choicesLLMs: How They Work and How to Choose One, the free local model, to the course’s demo ticket. It was recorded through TermOllamaA free program that downloads open models and runs them on your own computer. Your code calls it at a local address, as it calls a hosted API.Learn more Hosted and self-hosted choicesLLMs: How They Work and How to Choose One (shortened):

```json
{
"language": "en",
"team": "delivery",
"needs_human": true,
"reason": "The customer is inquiring about a delayed delivery and needs investigation, requiring human intervention to track the order.",
...
}
```
(not JSON)

The JSON inside is fine. But the model wrapped it in a TermMarkdownA plain-text way to show formatting, for example **bold** or # for a heading. Models often add it, so a program that expects plain JSON must remove it or reject the answer. fence: the three backticks and the word json that chat programs use to show code. So json.loads fails at the first character. Gemma 3 4B did this for 138 of 138 answers without structured output, with both prompt versions.

The TermHosted modelA model that runs on a provider’s computers. Your code sends it requests over the internet, and you pay for each call.Learn more Hosted and self-hosted choicesLLMs: How They Work and How to Choose One gives cleaner text, but it can still break the agreement. Ticket T-80009 says “My order LK-55190 never arrived”. The ID has five digits, but Larkfield order IDs have six. gpt-6-luna (chat-small on Azure) copied it as written:

"order": {
"order_id": "LK-55190",
"status": null
},

The Module 2 code took this answer as it was. No line of code knew the rules. An order ID must be LK- and six digits, team must be one of five names, and no field may be missing. Three things can go wrong with text that looks like data:

  1. It is not JSON at all.
  2. It is JSON, but a field is missing, or there is an extra field.
  3. Every field is there, but a value is not allowed: a sixth team, the text "maybe" for needs_human, a five-digit order ID.

The help desk needs the form with boxes: a written description of the answer’s shape that code can check. This is a schema. The word comes from the Greek skhēma, “shape” or “form”. A schema says:

  • which fields the answer has, and which are required;
  • the type of each value: text (a string), a number, true or false (a boolean), an object, a list, or null;
  • the allowed values: a fixed list of names is an enum (short for enumeration), and text can have a pattern that it must match;
  • nested objects: an object inside the answer, with its own fields.

JSON Schema is a standard format for writing such a description of JSON data. Many tools read it, and so do the model providers, as you will see below.

Validation is the check itself: does this answer follow the schema? It gives a clear yes, or a no with the reason.

A tiny example: five answers, one small schema

Section titled “A tiny example: five answers, one small schema”

Python has a popular library for this, Pydantic. You write a class with type hints, as you wrote a TermDataclassA Python class that only holds named fields, for example a ticket with an ID and a text. Python writes the usual code for it.Learn more Structured recordsPython for Practical AI Engineering in the Python course. Pydantic then checks data against those hints, and it says exactly what is wrong. It is in the project’s requirements.txt already.

Here is a schema with two fields, and five answers to check. These answers are hand-written for the example, not model outputs:

Python, in ~/projects/ticket-assistant
from typing import Literal
from pydantic import BaseModel, ValidationError
class Mini(BaseModel):
team: Literal["delivery", "returns", "payment", "warranty", "account"]
needs_human: bool
answers = [
'{"team": "delivery", "needs_human": false}',
'{"team": "shipping", "needs_human": false}',
'{"team": "delivery"}',
'{"team": "delivery", "needs_human": "maybe"}',
'```json\n{"team": "delivery", "needs_human": false}\n```',
]
for text in answers:
try:
print("pass", Mini.model_validate_json(text))
except ValidationError as error:
first = error.errors()[0]
print("fail", first["loc"], first["msg"])

The output:

pass team='delivery' needs_human=False
fail ('team',) Input should be 'delivery', 'returns', 'payment', 'warranty' or 'account'
fail ('needs_human',) Field required
fail ('needs_human',) Input should be a valid boolean, unable to interpret input
fail () Invalid JSON: expected value at line 1 column 1

Literal[...] is the enum: only these five texts. bool is the type. A field without a default value is required. model_validate_json reads the JSON text and checks it in one step. Each failure names the field (loc, the location) and the reason. The last line is the Markdown fence: it is not JSON, so Pydantic stops before it looks at any field.

The real answer has seven fields, and one of them is a nested object. The picture puts one real answer next to what the schema says about each field.

The real answer for ticket T-80008, field by field, with the rule of the schema for each: language enum en or fr; team enum of the five teams; needs_human boolean; reason text of at most 300 characters; confidence enum low, medium or high; order a nested object or null, with order_id matching LK- and six digits and status one of 7 statuses or null; reply text of 1 to 1,200 characters. All 7 fields are required and no other field is allowed.
One real answer (chat-small, T-80008) and the rule that the schema sets for each field.
Text description of the diagram

On the left, the answer that chat-small gave for ticket T-80008. On the right, what the schema says about each field. language: an enum, “en” or “fr”. team: an enum, one of the five teams. needs_human: a boolean, true or false. reason: a string of at most 300 characters. confidence: an enum, “low”, “medium” or “high”. order: a nested object, or null. Inside it, order_id is a string that must match the pattern ^LK-\d{6}$, and status is one of seven order statuses, or null. reply: a string of 1 to 1,200 characters. Below: all seven fields are required, and no other field is allowed (additionalProperties: false).

^LK-\d{6}$ is a regular expression: ^ is the start of the text, \d{6} is exactly six digits, and $ is the end. So LK-581106 matches, and LK-55190 does not.

Three choices need a reason:

  • order is required, but it may be null. A ticket without an order ID gets "order": null. A missing key would say nothing: did the model forget the field, or is there no order? The schema does not let the model leave that open.
  • No other field is allowed. An extra field, for example "refund_approved": true, is an error, not something to ignore.
  • status comes only from a lookup. The ticket text can claim anything, so the schema’s description tells the model to leave it null. Module 4 adds the lookup.

Structured output: send the schema with the request

Section titled “Structured output: send the schema with the request”

Pydantic checks an answer after the model wrote it. Many providers can also use the schema while the model writes. You send the schema in the request, in the field response_format. This is structured output.

How can a provider force a shape? The model writes one token at a time, chosen from a list of probabilities. You saw this in Predicting continuations, in LLMs: How They Work and How to Choose One. With structured output, the provider removes every token that would break the schema before the choice. After "team": ", only the start of one of the five team names is left. The model cannot write shipping, because no path through the schema allows it.

Strict mode ("strict": true) asks the provider to follow the schema exactly. In return, the provider sets rules for the schema itself, and the main one is: every field must be required.

TermAmiraLarkfield’s developer. She writes the code of the help-desk tools in the courses. saw this rule for real. She gave one field, order, a default value, so Pydantic left it out of the required list. gpt-6-luna on Azure refused the request before the model ran:

HTTP 400: Invalid schema for response_format 'ticket_analysis': In context=(), 'required' is required to be supplied and to be an array including every key in properties. Missing 'order'.

The same request to Gemma 3 4B through Ollama 0.40.1 was accepted with HTTP 200, and the model answered. One provider checks the schema and refuses it; another accepts it without a word. Providers support different parts of JSON Schema, so read your provider’s list before you design a schema.

Structured output has a cost too. The schema travels with every request: for prompt v2, the TermMedianThe middle value when you sort the values. Half are lower and half are higher. One very slow call does not change it much, so it is a good “usual” time.Learn more Measure practical performanceLLMs: How They Work and How to Choose One input grew from 1,662 to 1,904 tokens per ticket on Termchat-smallThe name that Larkfield’s code gives to a small, low-cost hosted model: gpt-6-luna on Azure. The code uses the name, so the model can change without a code change.Learn more What an LLM API isBuilding Reliable Applications with LLM APIs. That is US$0.32 to US$0.35 per 1,000 tickets at the price when we recorded (check the current price page).

What did it change? The chart counts the answers that are complete JSON and pass the schema, for the same 69 tickets and prompt v2.

  • text: the prompt asks for JSON
  • schema: structured output in the request
Prompt v2, 69 tickets. Gemma 3 4B through Ollama and gpt-6-luna (chat-small) on Azure, real recordings. For each model, the first bar has the schema only described in the prompt; '+ schema' adds structured output.
Text description of the chart

Four bars. Gemma 3 4B without structured output: 0 of 69 answers pass, because every answer is wrapped in a Markdown fence. Gemma 3 4B with structured output: 67 of 69. chat-small without structured output: 67 of 69. One answer was cut off at the token limit, and one copied the five-digit order ID. chat-small with structured output: 69 of 69.

Gemma went from 0 to 67. chat-small went from 67 to 69. But Gemma’s bar does not reach 69, although it had the schema. The next section shows why.

Here are the two Gemma answers that broke the schema with structured output. For ticket T-64722, which names no order, it wrote:

"order": {"order_id": "null", "status": null}

The order ID is the text "null", in quotes. For T-80009 it wrote "order_id": "LK-55190", five digits again. Both break the pattern ^LK-\d{6}$. Ollama did not enforce the schema’s pattern keyword. It kept the shape: the field names, the types and the enums. It ignored the pattern, and it said nothing about it.

So the rule of this module is simple: validate every answer in your own code, even when the provider promises structure. The provider’s check is a help. Your check is the guarantee. It costs microseconds, and it is the same code for every provider.

The hosted model shows the other side. With strict structured output, chat-small could not write five digits for T-80009. So it wrote six, and it invented the last one: LK-551902. That ID passes the schema, and it is wrong. The lesson Validate meaning catches it.

This picture shows where you are going in this module. A schema check is the first gate. A check of the meaning is the second. Only an answer that passes both goes into the database.

A ticket goes to generation, where the model writes an answer. Gate 1, the shape, checks that the answer is complete, is JSON and follows the schema. Gate 2, the meaning, checks the business rules against the orders table. Only an answer that passes both reaches the database. A provider error ends as failed; a gate 1 failure is rejected with refusal, truncated, malformed_json or schema; a gate 2 failure is rejected with codes such as order_not_in_ticket. After every failure, an agent handles the ticket without a draft and nothing is saved.
The module's pipeline: generation, then two gates, then the database. This lesson builds the schema that gate 1 uses.
Text description of the diagram

From left to right: a ticket from the help desk, and generation, where the model writes an answer. Gate 1, the shape: is the answer complete, is it JSON, does it follow the schema? Gate 2, the meaning: the business rules and the orders table. Last, the database, which holds valid results only. Three exits lead down. Generation can fail with no answer from the provider. Gate 1 rejects with the codes refusal, truncated, malformed_json or schema. Gate 2 rejects with codes such as order_not_in_ticket and safety_needs_human. After any exit, an agent handles the ticket without a draft, and nothing is saved.

The project gets a new file, assistant/schema.py, and one change in assistant/context.py. Work in ~/projects/ticket-assistant, with .venv active.

  1. Get the recorded answers of this module and Larkfield’s orders. They come from the snapshot end-of-m03 in the labs repository. First get the newest labs files:

    Terminal window
    git -C ../nextia-learning-labs pull

    If you downloaded the labs as a ZIP file, download it again instead. Then copy the files:

    Terminal window
    cp ../nextia-learning-labs/C08/snapshots/end-of-m03/recordings/*.jsonl recordings/
    cp ../nextia-learning-labs/C08/snapshots/end-of-m03/data/orders.sqlite data/

    recordings/ now has six files, and data/ has orders.sqlite next to tickets.csv. The orders are made up for this course, like the tickets.

  2. See the problem first. Ask for Gemma’s recorded answer to the demo ticket, with prompt v1:

    Terminal window
    python -m assistant analyse T-80008 --model gemma3:4b --prompt v1

    The output ends with:

    ```
    (not JSON)
    gemma3:4b | prompt v1 | finish_reason: stop | 1092 tokens in, 189 out | 5.795 s
  3. Make the file assistant/schema.py:

    assistant/schema.py
    """The shape of the assistant's answer, as a Pydantic model and as the JSON schema sent to the provider."""
    from typing import Literal
    from pydantic import BaseModel, ConfigDict, Field
    Team = Literal["delivery", "returns", "payment", "warranty", "account"]
    OrderStatus = Literal["processing", "shipped", "delivered", "return_requested", "return_received", "refunded",
    "cancelled"]
    class OrderRef(BaseModel):
    model_config = ConfigDict(extra="forbid")
    order_id: str = Field(pattern=r"^LK-\d{6}$", description="The order ID exactly as the ticket writes it.")
    status: OrderStatus | None = Field(description="Only from an order lookup result; otherwise null.")
    class TicketAnalysis(BaseModel):
    model_config = ConfigDict(extra="forbid")
    language: Literal["en", "fr"]
    team: Team
    needs_human: bool
    reason: str = Field(max_length=300)
    confidence: Literal["low", "medium", "high"]
    order: OrderRef | None
    reply: str = Field(min_length=1, max_length=1200)
    def response_format() -> dict:
    """Ask the provider for JSON that follows the schema (structured output, strict mode)."""
    return {
    "type": "json_schema",
    "json_schema": {"name": "ticket_analysis", "strict": True, "schema": TicketAnalysis.model_json_schema()},
    }
    Line Meaning
    extra="forbid" No other field is allowed: additionalProperties: false in the JSON Schema.
    order: OrderRef | None A nested object, or null. It has no default value, so the field is required.
    Field(pattern=...) The order ID must match the regular expression.
    description=... Goes into the JSON Schema, so the model reads it too.
    model_json_schema() Writes the class as a JSON Schema: the same rules, in the provider’s format.

    The rules live in one place. Pydantic checks answers with them, and response_format() sends the same rules to the provider.

  4. Open assistant/context.py, and replace the function build_request with this version:

    assistant/context.py
    def build_request(ticket: Ticket, model: str, version: str = "v2", response_format: dict | None = None) -> dict:
    """The body of one Chat Completions request. Only the fields that are set are sent."""
    request = {"model": model, "messages": build_messages(ticket, version), "max_completion_tokens": MAX_TOKENS}
    if response_format:
    request["response_format"] = response_format
    return request

    Without a schema, the request stays exactly as before, so every Module 2 command still finds its recording.

  5. Start python in the project folder, and send a request with structured output to the mock:

    Python, in ~/projects/ticket-assistant
    from assistant.context import build_request
    from assistant.data import load_ticket
    from assistant.providers import MockProvider
    from assistant.schema import TicketAnalysis, response_format
    mock = MockProvider()
    request = build_request(load_ticket("T-80008"), "chat-small", "v2", response_format())
    completion = mock.complete(request)
    print(completion.text)
    answer = TicketAnalysis.model_validate_json(completion.text)
    print(answer.team, answer.needs_human, answer.order.order_id)

    The output:

    {"language":"en","team":"delivery","needs_human":false,"reason":"The customer is asking for a delivery date for an order that has shipped.","confidence":"high","order":{"order_id":"LK-581106","status":null},"reply":"I’m sorry you haven’t received a delivery date for your garden shed. Our delivery team can help check the shipment and tracking information for your order."}
    delivery False LK-581106

    answer is now a Python object with attributes, not a dictionary. answer.order.order_id cannot be missing, and it always has six digits. Leave Python with exit().

  6. Make the file tests/test_schema.py. The tests keep the rules from changing by accident:

    tests/test_schema.py
    import pytest
    from pydantic import ValidationError
    from assistant.schema import TicketAnalysis, response_format
    GOOD = {"language": "en", "team": "delivery", "needs_human": False, "reason": "Late order.", "confidence": "high",
    "order": {"order_id": "LK-581106", "status": None}, "reply": "We are checking your order."}
    def test_a_good_answer_passes():
    assert TicketAnalysis.model_validate(GOOD).team == "delivery"
    @pytest.mark.parametrize("change", [
    {"team": "shipping"}, # not one of the five teams
    {"needs_human": "maybe"}, # not a boolean
    {"order": {"order_id": "LK-55190", "status": None}}, # not LK- and 6 digits
    {"order": {"order_id": "LK-581106", "status": "lost"}}, # not a known status
    {"reply": ""}, # empty reply
    {"extra": "field"}, # a field the contract does not have
    ])
    def test_impossible_answers_are_rejected(change):
    with pytest.raises(ValidationError):
    TicketAnalysis.model_validate({**GOOD, **change})
    def test_a_missing_field_is_rejected():
    answer = dict(GOOD)
    del answer["needs_human"]
    with pytest.raises(ValidationError):
    TicketAnalysis.model_validate(answer)
    def test_the_schema_sent_to_the_provider_is_strict():
    rf = response_format()
    schema = rf["json_schema"]["schema"]
    assert rf["json_schema"]["strict"] is True
    assert schema["additionalProperties"] is False
    assert set(schema["required"]) == set(schema["properties"]) # strict mode: every field is required

    The last test protects the strict-mode rule. If somebody gives a field a default value later, this test fails on their computer, not with an HTTP 400 in production.

  7. Run the tests:

    Terminal window
    python -m pytest

    The output ends with:

    30 passed, 1 deselected in 0.37s

    The parametrized test counts as six tests, one for each impossible answer.

Your turn: the local model with structured output

Section titled “Your turn: the local model with structured output”

Predict first: Gemma’s recorded answer to T-64722 was made with structured output. Will TicketAnalysis accept it? Then change step 5: use the ticket "T-64722" and the model "gemma3:4b", and run it again. Do the same for "T-80009".

Show the answer

Both answers are valid JSON in the right shape, and both are rejected. For T-64722, the output ends with this error (shortened):

pydantic_core._pydantic_core.ValidationError: 1 validation error for TicketAnalysis
order.order_id
String should match pattern '^LK-\d{6}$' [type=string_pattern_mismatch, input_value='null', input_type=str]

For T-80009, you get the same error with input_value='LK-55190'. The schema with the pattern was in the request, but Ollama did not enforce pattern. Your own check caught both.

Common mistake

The provider promised the schema, so the code skipped the check

Symptom: TermTomásLarkfield’s AI engineer. He often suggests a quick shortcut, and the lesson shows why it fails. switches the assistant to the local model to save money, with structured output on. He removes the Pydantic check: “The provider follows the schema now, so json.loads is enough.” The next day, an agent sees the order ID null in the help desk for ticket T-64722. A later step tries to look up an order called "null".

Cause: structured output is a provider feature, and providers implement different parts of JSON Schema. Ollama 0.40.1 kept the field names, types and enums, but it did not enforce pattern. The text "null" has the right type, so it passed.

Fix: keep TicketAnalysis.model_validate_json (or model_validate) on every answer, for every provider. Keep a test that feeds a recorded bad answer through the check. The provider’s feature makes valid answers more likely; your code decides what is valid.

Check your understanding

Why does Larkfield's schema make 'order' required, but allow it to be null? What would change with a default value?

Show the answer

Null says “this ticket names no order”. A missing key says nothing: the model may have forgotten the field. Required-but-nullable forces the model to decide. With a default value, Pydantic leaves order out of the required list. Strict structured output then fails: Azure answered HTTP 400 “Missing ‘order’”. The test test_the_schema_sent_to_the_provider_is_strict catches this before a request is sent.

Check your understanding

Gemma 3 4B gave 0 answers that pass the schema without structured output, and 67 with it. Is the check in your code still needed? Give the evidence.

Show the answer

Yes. Two answers fail with structured output: T-64722 with the order ID “null”, and T-80009 with “LK-55190”. Both are valid JSON with the right field names and types. Ollama did not enforce the pattern keyword, so only the check in the project’s code stops them. Providers differ in what they enforce; your own check is the same for every provider.

Check your understanding

Predict: with strict structured output, can chat-small still write the order ID LK-55190 for ticket T-80009? What did it do?

Show the answer

No. Strict structured output on Azure only lets the model write text that matches ^LK-\d{6}$, so five digits are impossible. The model wrote six digits and invented the last one: LK-551902 with prompt v2 (and LK-551904 with v1). The answer passes the schema and is still wrong. The schema can force a shape; it cannot make a value true.

Recap

  • A schema describes the answer’s shape: fields, types, required fields, enums, patterns and nested objects. Validation checks an answer against it.
  • assistant/schema.py holds Larkfield’s schema as a Pydantic model, TicketAnalysis. The same rules go to the provider as JSON Schema.
  • Structured output sends the schema with the request. Gemma 3 4B went from 0 to 67 of 69 valid answers, and chat-small from 67 to 69. It costs about 240 input tokens per ticket.
  • Strict mode needs every field in the required list; Azure refused a schema with an optional field.
  • Ollama did not enforce pattern, so the project validates every answer in its own code.

Next, you handle the answers that are not complete: refusals, answers cut off at the token limit, and text that is not JSON.

Next: Handle incomplete results