Get structured output
Language models are good at reading text written for people: emails, reports, reviews, notes. The trouble is that their answers are written for people too. That’s fine when a person reads the answer, but when a program needs to use it, you want data: fields with names, numbers that are numbers, and the same shape every time. That’s what structured output gives you.
On this page, you’ll learn how to describe the shape you want with a schema, send it with your request, and read the answer as ordinary data in your language. We’ll start by asking for records without a schema, to see why you need one. Then we’ll write a schema and use the records it gives back. Next, we’ll look at a subtle way a schema can go wrong, and how to avoid it. We’ll finish by looking at which providers support schemas, and what LM15 does when one doesn’t.
To run the examples, you’ll need LM15 installed and an API key; see Make your first request. The examples build on each other, and the whole program is near the end.
Field notes
Section titled “Field notes”The examples in these guides follow one small program: an assistant for a wildlife research station. The station’s observers check cameras and walk paths, and write down what they see in their own words. Here’s this morning’s note from the camera by the stream:
note = """Checked the stream camera this morning. Three
badgers came through overnight, one of them limping.
A fox passed later, just before dawn."""It records three badgers and a fox, both at the stream, along with a couple of details: one badger was limping, and the fox came later. The station keeps its sightings as records, each with a species, a count, and a place, like the ones the assistant searches in Call your own functions. Let’s ask the assistant to turn the note into records:
request = Request(
model="provider:model",
system=(
"You are the field assistant for a wildlife research "
"station. Turn each field note into sighting records."
),
messages=[Message.user(note)],
)
router = LMRouter()
response = router.complete(request)
print(response.text)| Record | Location | Species | Count | Time | Notes | |---|---|---|---:|---|---| | 1 | Stream camera | Badger | 3 | Overnight | One individual was limping. | | 2 | Stream camera | Fox | 1 | Just before dawn | Passed after the badgers. | Observation date was not specified; footage was reviewed this morning.
That’s a perfectly good answer for a person, but it’s hard for a program to use. The model chose the columns itself, so your program would have to work out that “Stream camera” is where the animals were. When we asked a second time, the columns changed. What we really want is an answer in a shape that we choose.
Describing the shape
Section titled “Describing the shape”A schema describes what a piece of data looks like: which fields it has, and what each one can hold. LM15 uses JSON Schema, the same format that describes a tool’s inputs. We want a list of sightings, each with three fields:
| Field | What it holds |
|---|---|
species |
The animal’s common name, singular |
count |
A whole number |
place |
One of the station’s three places |
Here’s the schema:
places = ["oak grove", "stream", "meadow"]
sighting_schema = {
"type": "object",
"properties": {
"sightings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"species": {
"type": "string",
"description": "Common name, singular.",
},
"count": {"type": "integer"},
"place": {
"type": "string",
"enum": places,
},
},
"required": ["species", "count", "place"],
"additionalProperties": False,
},
},
},
"required": ["sightings"],
"additionalProperties": False,
}It’s easiest to read from the outside in. The answer is an object with one
field, sightings, which is a list (an array). Each item in the list is an
object with the three fields in the table. Three keywords do most of the work:
requiredlists the fields that must be present. Describing a field underpropertiesisn’t enough on its own.enumlists the only values a field may take.additionalProperties: falsestops the model from adding fields of its own.
The description on species is for the model: it says how to write the name.
Notice that the station’s places are listed once, at the top, and that the
schema allows only those three. We’ll come back to that.
Asking for the shape
Section titled “Asking for the shape”To use the schema, add it to the request’s settings as the answer format.
strict asks the provider to follow it exactly:
request = Request(
model="provider:model",
system=(
"You are the field assistant for a wildlife research "
"station. Turn each field note into sighting records."
),
messages=[Message.user(note)],
config=Config(response_format={
"type": "json_schema",
"name": "sightings",
"schema": sighting_schema,
"strict": True,
}),
)
router = LMRouter()
response = router.complete(request)
print(response.data){
"sightings": [
{"species": "badger", "count": 3, "place": "stream"},
{"species": "fox", "count": 1, "place": "stream"}
]
}- finish reason
- stop
- input tokens
- 130
- output tokens
- 41
The answer now has exactly the shape we described: two sightings, each with a species, a count, and a place. (It’s also shorter, 41 output tokens instead of 156, but that’s a side effect you shouldn’t count on.)
The details that don’t fit the schema are gone. There’s no field for the limp or the time, so the model left them out. A schema decides what you keep as well as how it’s laid out, so if you need a detail, give it a field. It’s also a good idea to store the original note alongside the records, so you can always go back to it.
Using the records
Section titled “Using the records”Because the answer is JSON with a shape you know, LM15 can hand it to you as ordinary data in your language: dictionaries and lists, a JSON value, or, in Go, a struct you declare. That means you can use the records directly:
for s in response.data["sightings"]:
print(s["count"], s["species"], "at", s["place"])With the answer above, every language prints:
3 badger at stream
1 fox at stream
Reading JSON isn’t the same as checking it, though. LM15 turns the answer into data, but it doesn’t check that data against your schema, and a type you declare for it is an assumption rather than a check. It’s also worth looking at the finish reason before you store anything: an error, or an answer that was cut short, isn’t the same as an empty list of sightings.
When a note doesn’t fit
Section titled “When a note doesn’t fit”The schema works well for the stream note. Here’s a note from another part of the station:
note = """Around midnight an owl was calling from the old
barn roof, probably a tawny. Two hares in the barn
field at first light."""Before you look at the answer, look at the schema again. It allows only three places: the oak grove, the stream, and the meadow. Where can the owl go?
{
"sightings": [
{"species": "tawny owl", "count": 1, "place": "meadow"},
{"species": "hare", "count": 2, "place": "meadow"}
]
}The owl was on the barn roof, but the record says it was in the meadow. This is a subtle but important problem. The answer follows the schema exactly, so nothing in your program will complain, yet it’s wrong. We asked for one of three places, the barn isn’t one of them, and so the model picked one that was allowed.
This isn’t a quirk of one model. When we sent the same request to other providers, every one that followed the schema put the owl somewhere it wasn’t: OpenAI in the meadow, Anthropic in the meadow, Gemini in the meadow, and OpenRouter in the oak grove. The lesson is worth remembering: a schema controls the shape of an answer, not whether it’s true.
Leaving the model a way out
Section titled “Leaving the model a way out”The fix is to give the model a way to say “none of these”. Add "other" to
the list of places:
places = ["oak grove", "stream", "meadow", "other"]
sighting_schema = {
"type": "object",
"properties": {
"sightings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"species": {
"type": "string",
"description": "Common name, singular.",
},
"count": {"type": "integer"},
"place": {
"type": "string",
"enum": places,
},
},
"required": ["species", "count", "place"],
"additionalProperties": False,
},
},
},
"required": ["sightings"],
"additionalProperties": False,
}{
"sightings": [
{"species": "tawny owl", "count": 1, "place": "other"},
{"species": "hare", "count": 2, "place": "other"}
]
}Now the owl is recorded under “other”, which is true, and easy for your program to find later so a person can take a look. In our runs, every model that had the option chose it for the owl.
A good rule of thumb is that any field with a fixed list of values should include one for when nothing on the list fits. The same goes for any field the model must fill in, like a species nobody could identify: if you don’t leave room for an honest answer, you’ll get a dishonest one.
“Other” isn’t a place, though. It only says “not one of these”, so if you need to know where, add a text field for the observer’s own words. And a list of places can’t say what each place means. The hares were in “the barn field”: is that the meadow, or somewhere else? In our runs, models answered both ways. If the difference matters, describe each place, in the schema or in the instructions.
The whole program
Section titled “The whole program”Here’s everything together, with the fixed list of places:
from lm15 import Config, LMRouter, Message, Request
places = ["oak grove", "stream", "meadow", "other"]
sighting_schema = {
"type": "object",
"properties": {
"sightings": {
"type": "array",
"items": {
"type": "object",
"properties": {
"species": {
"type": "string",
"description": "Common name, singular.",
},
"count": {"type": "integer"},
"place": {
"type": "string",
"enum": places,
},
},
"required": ["species", "count", "place"],
"additionalProperties": False,
},
},
},
"required": ["sightings"],
"additionalProperties": False,
}
note = """Checked the stream camera this morning. Three
badgers came through overnight, one of them limping.
A fox passed later, just before dawn."""
request = Request(
model="provider:model",
system=(
"You are the field assistant for a wildlife research "
"station. Turn each field note into sighting records."
),
messages=[Message.user(note)],
config=Config(response_format={
"type": "json_schema",
"name": "sightings",
"schema": sighting_schema,
"strict": True,
}),
)
router = LMRouter()
response = router.complete(request)
print(response.data)
for s in response.data["sightings"]:
print(s["count"], s["species"], "at", s["place"])A real program would loop over the day’s notes and store each record. It would also fill in what it already knows, such as who filed the note and when, rather than ask the model. When the animals were seen is a different matter: that’s in the note, so if you need it, give it a field.
Providers and schemas
Section titled “Providers and schemas”Not every provider can hold a model to a schema. We sent the same two requests to six providers: the stream note with the fixed schema, and the barn note with the original three places.
| Provider | Model | The schema | The owl, three places |
|---|---|---|---|
| OpenAI Codex | gpt-5.6-sol | followed | meadow |
| OpenAI | gpt-5-mini | followed | meadow |
| Anthropic | claude-haiku-4-5 | followed | meadow |
| Google Gemini | gemini-2.5-flash | followed | meadow |
| OpenRouter | openai/gpt-4o-mini | followed | oak grove |
| DeepSeek | deepseek-chat | refused by the provider | |
| Z.AI | glm-4.5-air | ignored (LM15 dropped it) |
LM15 translates your schema into each provider’s own format, so your code stays the same. What happens next depends on the provider:
- Most follow the schema, and the answer has the shape you asked for.
- Some refuse it. DeepSeek replied with an error, “This response_format type is unavailable now”, which LM15 passes on to you.
- Some accept it and then ignore it. Z.AI answered with Markdown again. LM15 knows this about Z.AI and won’t let it happen quietly. In Python, TypeScript, Rust, and Go, it sends the request without the schema and records on the response what it left out, and why:
this server accepts response_format type 'json_schema' and does not apply it; use {'type': 'json_object'} and describe the shape in the prompt
In R and Julia, it refuses to send the request at all. Either way, you find out from your program rather than from a record that looks fine.
The reason above mentions the other answer format, json_object, which asks
for any valid JSON. It guarantees JSON, but not your shape. Asked that way,
DeepSeek chose its own fields:
{
"sightings": [
{
"species": "badger",
"count": 3,
"notes": "one limping",
"time": "overnight",
"location": "stream camera"
},
{
"species": "fox",
"count": 1,
"time": "just before dawn",
"location": "stream camera"
}
]
}
They’re sensible fields, but none of them is place. Valid JSON isn’t the same
as the records you asked for, so if your program needs a particular shape, use
a schema, with a provider that follows it.
These results are for these models on September 23, 2026. Other models from the same provider may behave differently.
Try it yourself
Section titled “Try it yourself”Predict what will happen before you run each one.
- Send this note, from the edge of the oak grove, with the fixed schema: Dusk, edge of the oak grove. Two deer browsing on fallen acorns, one small with spots still showing. Too far to be sure of the species: roe or fallow. What will the model write as the species?
- Take
"other"out again, and send a note about a hedgehog in the station’s car park. Where will the hedgehog be recorded? - Keep the three places. Would a program that checks every answer against the schema catch the owl in the wrong place? What could?
What to expect
- When we ran it, the model wrote deer, not roe or fallow. The note doesn’t settle the species, and the model didn’t pretend it did. Don’t count on that, though: if it matters, give the model a way to say it isn’t sure, as you did with “other”.
- At one of the three places. The car park isn’t on the list, so it’s the same problem as the owl.
- No. The wrong place is an allowed value, so the answer passes every check. Only the note shows that it’s wrong, which is one more reason to keep the note with the record.
Structured output is also the basis for decisions: which of these categories, where on this scale, yes or no. Ask for judgments with probabilities builds on this page, and adds how sure the model was of each answer. The Overview shows where the answer format sits among a request’s other settings.