Local LLMs with Ollama · Lesson 7 of 13 · about 30 minutes

Structured output: answers your code can trust

Asking a model for JSON instead of free text, with Ollama's format option and a JSON schema. Every one of 40 answers came back as valid JSON, ready for code. But the score fell from 9 out of 10 to 5, and a control run showed why. What structured output really buys you, what it costs on a small model, and how to tell the two apart.

By the end you'll know

  • What structured output is, and why code needs it
  • How to ask Ollama for JSON, with format: "json" or a JSON schema
  • What the schema guarantees, and what it doesn't
  • Why a control run is the only way to know what a change did
  • Why every field in a structured answer still needs checking

Where you are

So far, the model has answered in plain text: a name, or NONE. That worked, 9 times out of 10. But look at what the code has to assume. That the reply is just a name, with no "Based on the page…" in front. That NONE is spelled exactly NONE. That a name never includes a title. Lesson 5's vague prompt showed what happens when those assumptions break: ten paragraphs, nothing a program could use.

Structured output fixes the shape. Instead of free text, the model answers in JSON (JavaScript Object Notation), a format every programming language can read: named fields, with values that are text, numbers, or null for "nothing". Code doesn't have to guess where the name is. It reads the field called full_name.

Think of a paper form versus a letter. Ask a hundred people to write you a letter saying who their mayor is, and you'll spend all day finding the name in each one. Hand them a form with a box labeled "Mayor's full name", and you can type them straight into a spreadsheet. The form doesn't make anyone more accurate. It makes their answers easy to process.

That last sentence is the whole lesson, and the results below prove it.

Asking for JSON

Ollama has a request field for this: format. It can take two kinds of value.

"json" tells Ollama the reply must be valid JSON. The prompt says which fields you want; format makes sure whatever comes back can be parsed.

A JSON schema goes further. A schema is a small description of the exact shape you want: which fields, what type each one is, which are required. Here's the one structured.py uses:

PYTHON
SCHEMA = {
    "type": "object",
    "properties": {
        "full_name": {"type": ["string", "null"]},
        "title": {"type": ["string", "null"]},
    },
    "required": ["full_name", "title"],
}

Two fields, full_name and title. Each is either text or null, and both must be there. With a schema in format, Ollama holds the model to that shape while it writes: it can't add a field, leave one out, or put a paragraph where a name should go.

The prompt asks for the same shape in words, and uses null instead of NONE:

TEXT
Read this web page and find the person currently serving as mayor or acting mayor.
Respond with JSON only, in this shape: {"full_name": string or null, "title": string or null}
Use null for full_name if the page does not name a current mayor or acting mayor.

The request is Lesson 4's, with format added:

PYTHON
payload = {
    "model": MODEL,
    "prompt": prompt.format(page=page),
    "stream": False,
    "options": {"temperature": 0},
}
if fmt is not None:
    payload["format"] = fmt

And reading the answer is now json.loads, which turns the reply into a Python dictionary, with None for JSON's null:

PYTHON
data = json.loads(raw)
name = data.get("full_name") or "NONE"

The results

structured.py in the practice set runs this against all ten pages, once with format: "json" and once with the schema. Pull the latest practice set (git pull) and run it:

BASH
python3 structured.py json schema
OUTPUT
== json
   ok   Cedar Bend     full_name Dana Whitfield       title Mayor
   ok   Elm Ridge      full_name Carla Mendes         title Mayor Pro Tem
   MISS Alder Creek    full_name None                 title None
   ok   Birch Hollow   full_name None                 title None
   MISS Fox Run        full_name None                 title None
   MISS Juniper Flats  full_name Mayor Tom Haskell    title acting mayor
   MISS Heron Point    full_name None                 title None
   ok   Quarry Hill    full_name None                 title None
   MISS Willow Bend    full_name Robert "Bob" Chen    title Mayor
   ok   Stone Mill     full_name Ana Silva            title Mayor
   5 of 10 exactly right, 0 not valid JSON

== schema
   ok   Cedar Bend     full_name Dana Whitfield       title Mayor
   ok   Elm Ridge      full_name Carla Mendes         title Mayor Pro Tem
   MISS Alder Creek    full_name None                 title None
   ok   Birch Hollow   full_name None                 title None
   MISS Fox Run        full_name None                 title None
   MISS Juniper Flats  full_name Mayor Tom Haskell    title acting mayor
   MISS Heron Point    full_name None                 title None
   ok   Quarry Hill    full_name None                 title None
   MISS Willow Bend    full_name Robert "Bob" Chen    title Mayor
   ok   Stone Mill     full_name Ana Silva            title Mayor
   5 of 10 exactly right, 0 not valid JSON

The good news first: 20 replies, 20 valid JSON objects. Every one parsed. null arrived as a real empty value, not a word to compare. And there's a new field: Elm Ridge's title says Mayor Pro Tem, information that a plain name throws away, and that code could use to flag "this is an acting mayor" for a person to check.

The bad news: 5 out of 10, down from 9 with plain text. Alder Creek, Fox Run and Heron Point came back empty. Juniper Flats came back as Mayor Tom Haskell, title stuck to the name.

One change at a time

It's tempting to conclude that asking for JSON makes the model worse. But that conclusion would be a guess, because two things changed at once: the format option, and the prompt. The JSON prompt is worded differently from Lesson 4's, and asks for a title too. Either change could explain the drop.

When two things change and the result moves, you can't tell which one did it. The fix is a control run: change one thing back, and see whether the result changes with it. structured.py has two more variants for exactly that:

  • none is the same JSON prompt, with no format at all. If format caused the drop, none should do better.
  • narrow-schema is Lesson 4's wording, the one that scored 9, asking only for full_name, with a one-field schema. If the wording was the problem, this should get back to 9.
BASH
python3 structured.py none narrow-schema
OUTPUT
== none
   ok   Cedar Bend     full_name Dana Whitfield       title Mayor
   ok   Elm Ridge      full_name Carla Mendes         title Mayor Pro Tem
   MISS Alder Creek    full_name None                 title None
   ok   Birch Hollow   full_name None                 title None
   MISS Fox Run        full_name None                 title None
   MISS Juniper Flats  full_name Mayor Tom Haskell    title acting mayor
   MISS Heron Point    full_name None                 title None
   ok   Quarry Hill    full_name None                 title None
   MISS Willow Bend    full_name Robert "Bob" Chen    title Mayor
   ok   Stone Mill     full_name Ana Silva            title Mayor
   5 of 10 exactly right, 0 not valid JSON

== narrow-schema
   ok   Cedar Bend     full_name Dana Whitfield       title -
   ok   Elm Ridge      full_name Carla Mendes         title -
   ok   Alder Creek    full_name Grace Okafor         title -
   ok   Birch Hollow   full_name None                 title -
   MISS Fox Run        full_name None                 title -
   MISS Juniper Flats  full_name Rosa Delgado         title -
   ok   Heron Point    full_name Samuel Adeyemi       title -
   ok   Quarry Hill    full_name None                 title -
   MISS Willow Bend    full_name Robert               title -
   MISS Stone Mill     full_name None                 title -
   6 of 10 exactly right, 0 not valid JSON

Now the picture is clear:

  • format didn't change a single answer. none gave exactly the same ten answers as json and schema. On this model, the prompt alone was enough to get valid JSON every time, and the format option didn't move the score. So the drop came from the prompt.
  • Asking for JSON at all still cost accuracy. Even with Lesson 4's proven wording, narrow-schema scored 6, not 9. It found Alder Creek and Heron Point, but lost Stone Mill, cut Willow Bend to a first name, and, worst of all, named Rosa Delgado, the mayor-elect: the same dangerous wrong person Lesson 5 saw.

Compare every variant, and every answer, below:

Try it: five ways to ask

A prompt asking for {"full_name", "title"}, with format: "json".

5 of 10 right, 10 of 10 valid JSON. Valid JSON every time, and a title field. But 5 right, not 9.

  • Cedar Bend{"full_name": "Dana Whitfield", "title": "Mayor"}right
  • Elm Ridge{"full_name": "Carla Mendes", "title": "Mayor Pro Tem"}right
  • Alder Creek{"full_name": null, "title": null}empty: safe miss
  • Birch Hollow{"full_name": null, "title": null}right
  • Fox Run{"full_name": null, "title": null}empty: safe miss
  • Juniper Flats{"full_name": "Mayor Tom Haskell", "title": "acting mayor"}right person, wrong form
  • Heron Point{"full_name": null, "title": null}empty: safe miss
  • Quarry Hill{"full_name": null, "title": null}right
  • Willow Bend{"full_name": "Robert \"Bob\" Chen", "title": "Mayor"}right person, wrong form
  • Stone Mill{"full_name": "Ana Silva", "title": "Mayor"}right

So what does structured output buy you?

Put the five runs side by side and the honest answer is: reliability, not accuracy.

  • What it bought: 40 replies, 40 valid JSON objects. No paragraphs, no "Based on the information provided", no guessing whether NONE is spelled NONE. Code that reads data["full_name"] won't break on a strange reply. And extra fields like title let the model hand over more than one fact at a time.
  • What it cost here: accuracy. Plain text scored 9; the best JSON version scored 6. On this small model, asking for an answer in a different shape changed which answers it gave.
  • What format itself does: it guarantees the shape. On these pages, the 3B model kept to the shape without being forced. On longer or messier pages, or with more fields, it might not, and that's when format earns its place: a schema means a reply can never be something your code can't read.

That's not a reason to avoid structured output. It's a reason to test it like any other change. Two things to watch:

  • Every field can be wrong. Juniper Flats' title says "acting mayor". Tom Haskell is the mayor; the page says he "continues to serve" until Rosa Delgado is sworn in. The schema made sure title was a string. It couldn't make it true. Lesson 8 is about checking values against the page.
  • More fields can mean more mistakes. The two-field JSON prompt scored lower than the one-field version. Ask for what you'll actually use.

In my real setup

CivicGrid's weekly mayor check uses structured output, with a schema much like the one in this lesson: full_name and title, each a string or null, both required, and format set to the schema. It runs on the bigger qwen2.5:7b, with a longer prompt.

It needs the guarantee. The check runs unattended on about 2,400 cities, every week, with nobody watching. A reply that wasn't valid JSON, on page 1,700, at 3 a.m., would either crash the run or be quietly skipped. With a schema, every reply parses.

Does asking for JSON cost the 7B model accuracy the way it cost the 3B here? I don't know yet. This lesson is the reason I'll find out: Lesson 10 runs the same pages both ways and compares, instead of assuming.

Spot the problem

Three situations. Work out what's wrong before you read the explanation.

Blaming the wrong change. A team switches their extraction script to JSON output and rewrites the prompt in the same change. Accuracy drops, so they switch back to plain text and decide "JSON makes models worse". What did they skip? A control run. Two things changed together, so they can't know which caused the drop. In this lesson, the format option made no difference; the wording did.

Valid but wrong. A script's JSON schema guarantees every reply has full_name and title as strings. The team stops checking the values, since "the schema validates everything". A week later they find people listed as "acting mayor" who aren't. What went wrong? A schema checks the shape, not the facts. "title": "acting mayor" is a perfectly valid string. Values still need checking against the source.

Asking for everything. Someone extends their schema from two fields to twelve: name, title, phone, email, term start, term end and more, "since it's free". Their name accuracy drops. Why might that happen? Every field is more for a small model to get right, and this lesson saw a two-field prompt score lower than a one-field version. Ask for the fields you'll actually use, and test after adding any.

Where you are now

Structured output makes a model answer in JSON, so code reads named fields instead of guessing at free text. Ollama's format takes "json" or a JSON schema, and a schema holds the model to an exact shape. On the practice pages, every one of 40 replies was valid JSON, and the title field added useful detail. But accuracy fell from 9 to 5, and a control run showed it was the prompt, not format, that changed the answers; even the best JSON version scored 6, and named the wrong person once. Structured output buys reliability, not accuracy, so test it like any other change, and check every field. Lesson 8 is about exactly that: catching wrong answers before anyone trusts them.

Check yourself

  1. What does structured output change about a model's answer?

  2. What does setting format to a JSON schema guarantee?

  3. The json and none runs gave identical answers. What does that show?

  4. Why run a control when two things changed at once?

  5. The title field said "acting mayor" for a mayor. What should code do?

  6. When does the format option earn its place?

Key terms

  • Structured output: a model's answer in a fixed, machine-readable shape, instead of free text.
  • JSON: JavaScript Object Notation, a text format of named fields that every programming language can read.
  • null: JSON's value for "nothing". Python reads it as None.
  • format: Ollama's request field for structured output: "json", or a JSON schema.
  • JSON schema: a description of the exact shape a JSON answer must have: its fields, their types, which are required.
  • Control run: a run with one change undone, to find out which change caused a result.

Go deeper