Define a schema
- Lesson 8
- 50 min
- Module 3: Structured outputs and validation
Lesson 8 of 19 · Building Reliable Applications with LLM APIsDefine a schema
1. Start with one model request
2. Prompting as an engineering task
3. Structured outputs and validation
4. Tool calling safely
5. Conversations and streaming
6. Reliability and cost controls
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 ready
What you need to know
- The prompt’s output contract, from Specify the task, and the
evalcommand, from Compare prompts. finish_reasonand the token limitmax_completion_tokens, from Request anatomy.- Type hints and dataclasses, from Structured records in Python for Practical AI Engineering. JSON in Python, from Reading and writing files: CSV and JSON. Tests with pytest, from Focused tests.
What you need on your computer
- The
ticket-assistantproject at the end of Module 2, in~/projects/ticket-assistant, with its.venvactive.python -m pytestshows21 passed, 1 deselected. - If your project is broken or missing, copy the snapshot
end-of-m02with 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.
A form with boxes
Section titled “A form with boxes”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.
The problem: text that looks like data
Section titled “The problem: text that looks like data”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:
- It is not JSON at all.
- It is JSON, but a field is missing, or there is an extra field.
- Every field is there, but a value is not allowed: a sixth team, the text
"maybe"forneeds_human, a five-digit order ID.
The idea: a schema
Section titled “The idea: a schema”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:
from typing import Literalfrom 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=Falsefail ('team',) Input should be 'delivery', 'returns', 'payment', 'warranty' or 'account'fail ('needs_human',) Field requiredfail ('needs_human',) Input should be a valid boolean, unable to interpret inputfail () Invalid JSON: expected value at line 1 column 1Literal[...] 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.
Larkfield’s schema
Section titled “Larkfield’s schema”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.
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:
orderis required, but it may benull. 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. statuscomes only from a lookup. The ticket text can claim anything, so the schema’s description tells the model to leave itnull. 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
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.
Validate anyway
Section titled “Validate anyway”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.
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.
Build it: the schema in the project
Section titled “Build it: the schema in the project”The project gets a new file, assistant/schema.py, and one change in assistant/context.py. Work in ~/projects/ticket-assistant, with .venv active.
-
Get the recorded answers of this module and Larkfield’s orders. They come from the snapshot
end-of-m03in the labs repository. First get the newest labs files:Terminal window git -C ../nextia-learning-labs pullIf 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/Terminal window Copy-Item ..\nextia-learning-labs\C08\snapshots\end-of-m03\recordings\*.jsonl recordings\Copy-Item ..\nextia-learning-labs\C08\snapshots\end-of-m03\data\orders.sqlite data\recordings/now has six files, anddata/hasorders.sqlitenext totickets.csv. The orders are made up for this course, like the tickets. -
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 v1The output ends with:
```(not JSON)gemma3:4b | prompt v1 | finish_reason: stop | 1092 tokens in, 189 out | 5.795 s -
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 Literalfrom pydantic import BaseModel, ConfigDict, FieldTeam = 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: Teamneeds_human: boolreason: str = Field(max_length=300)confidence: Literal["low", "medium", "high"]order: OrderRef | Nonereply: 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: falsein the JSON Schema.order: OrderRef | NoneA 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. -
Open
assistant/context.py, and replace the functionbuild_requestwith 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_formatreturn requestWithout a schema, the request stays exactly as before, so every Module 2 command still finds its recording.
-
Start
pythonin the project folder, and send a request with structured output to the mock:Python, in ~/projects/ticket-assistant from assistant.context import build_requestfrom assistant.data import load_ticketfrom assistant.providers import MockProviderfrom assistant.schema import TicketAnalysis, response_formatmock = 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-581106answeris now a Python object with attributes, not a dictionary.answer.order.order_idcannot be missing, and it always has six digits. Leave Python withexit(). -
Make the file
tests/test_schema.py. The tests keep the rules from changing by accident:tests/test_schema.py import pytestfrom pydantic import ValidationErrorfrom assistant.schema import TicketAnalysis, response_formatGOOD = {"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 Trueassert schema["additionalProperties"] is Falseassert set(schema["required"]) == set(schema["properties"]) # strict mode: every field is requiredThe 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.
-
Run the tests:
Terminal window python -m pytestThe output ends with:
30 passed, 1 deselected in 0.37sThe 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 TicketAnalysisorder.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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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.
Does your answer have the main points of ours?
Sign in and enroll in the course to save your answers. Every check counts toward the certificate.
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.pyholds 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.