Structured Output

Get replies a program can read: specify the exact JSON shape, allowed values and what to do with missing data, then parse and check every reply in code.

15 minLesson 5 of 8
On this page
  1. Specify the shape, not just the content
  2. Check every reply
  3. Designing a shape that works
  4. Try it yourself
  5. Common mistakes
  6. Check yourself
  7. What to practise next

You have 300 customer emails and want each one as a row in a spreadsheet: name, order number, type of problem. You write a prompt, run it on the first email, and the reply opens with a cheerful "Sure, here are the details" followed by bold labels and a bulleted list. A person reads that fine. Your script, which expected three clean fields, crashes on the first line.

When a program reads the reply, the reply is no longer prose. It is data, and the program can only read it if the format is exactly what the code expects.

Specify the shape, not just the content

Asking a model to "extract the name, order number and issue" tells it what to find. It says nothing about how to write it down, so the model picks the format a helpful chat reply usually has.

Content onlyIllustrative example. Real replies vary.
You
Extract the customer's name, order number and issue from this email. Hi, this is Dana Whitfield. My blender (order 48213) arrived yesterday and the lid was cracked in two places. Can you help?
Model
Sure! Here are the details: - **Name:** Dana Whitfield - **Order number:** #48213 - **Issue:** The blender arrived with a cracked lid.

For a program, everything about this is wrong: the friendly opener, the markdown, the # added to the number, and an issue written as a free sentence that cannot be counted. A good structured-output prompt settles five things:

  1. The format. Usually JSON, because every language can parse it.
  2. The exact field names and types. order_id as a string of digits, not "Order number".
  3. The allowed values for any field you will group or count by.
  4. What to do when something is missing. Use null; never guess.
  5. Nothing else. No text before or after the JSON.
Shape specifiedIllustrative example. Real replies vary.
You
Extract details from the customer email below. Reply with one JSON object and nothing else: no explanation, no markdown. Use exactly this shape: { "name": string or null, "order_id": string of digits only, or null, "issue_type": one of "damaged", "late", "wrong-item", "other" } If a value is not in the email, use null. Do not guess. <email> Hi, this is Dana Whitfield. My blender (order 48213) arrived yesterday and the lid was cracked in two places. Can you help? </email>
Model
{ "name": "Dana Whitfield", "order_id": "48213", "issue_type": "damaged" }

The template does the work of an example, as in Show Examples, while the rules around it set the limits. Asking for null matters most: without it, a missing order number tends to come back as something plausible-looking, which is far worse than an empty field.

Check every reply

Even with a clear prompt, across hundreds of calls you will see the occasional reply wrapped in a markdown code fence, with a value outside your list, or with a missing field. Treat model output like input from a user: parse it, check it, and only then trust it.

Checking one replyUse the arrows or ← → keys
strip a ```json fence if presentparse as JSON; check every field is presentcheck name: string or nullcheck order_id: digits or nullcheck issue_type: in the allowed listif any check failed: report the errors
  1. checks
    checkresult
    fencefound
    parse
    name
    order_id
    issue_type
    errors
    0

    1A reply arrives for a different email. It starts with ```json: the model wrapped the JSON in a markdown code fence, a common slip even when told not to.

  2. checks
    checkresult
    fencestripped
    parseok
    name
    order_id
    issue_type
    errors
    0

    2The fence is harmless if you expect it, so the code strips it. What is left parses as JSON, and all three fields are there.

  3. checks
    checkresult
    fencestripped
    parseok
    nameok
    order_id
    issue_type
    errors
    0

    3name is "Sam Ortiz", a string. Fine.

  4. checks
    checkresult
    fencestripped
    parseok
    nameok
    order_idok (null)
    issue_type
    errors
    0

    4order_id is null. This email had no order number, and null is exactly what the prompt asked for in that case.

  5. checks
    checkresult
    fencestripped
    parseok
    nameok
    order_idok (null)
    issue_typenot allowed
    errors
    1

    5issue_type is "late delivery". Close in meaning to late, but not on the list. Counting by issue type would put it in its own group.

  6. checks
    checkresult
    fencestripped
    parseok
    nameok
    order_idok (null)
    issue_typenot allowed
    errors
    1
    result
    rejected

    6One error, so the reply is rejected with a message naming the problem. The program can send the email again with that message added, or set it aside for a person. Either way, a bad value never reaches the spreadsheet.

1 / 6

Here is the same check in code. It returns either the clean data or a list of errors you can show to a person or send back to the model.

const FIELDS = ["name", "order_id", "issue_type"];
const ISSUE_TYPES = ["damaged", "late", "wrong-item", "other"];
 
function parseTicket(reply) {
  const text = reply.trim().replace(/^```(json)?\s*/, "").replace(/\s*```$/, "");
  let data;
  try { data = JSON.parse(text); } catch { return { ok: false, errors: ["not valid JSON"] }; }
  if (typeof data !== "object" || data === null || Array.isArray(data)) {
    return { ok: false, errors: ["expected one JSON object"] };
  }
  const missing = FIELDS.filter((f) => !(f in data));
  if (missing.length) return { ok: false, errors: missing.map((f) => "missing " + f) };
  const errors = [];
  const { name, order_id: orderId } = data;
  if (name !== null && typeof name !== "string") errors.push("name must be a string or null");
  if (orderId !== null && !(typeof orderId === "string" && /^[0-9]+$/.test(orderId))) {
    errors.push("order_id must be digits or null");
  }
  if (!ISSUE_TYPES.includes(data.issue_type)) errors.push("issue_type must be one of " + ISSUE_TYPES.join(", "));
  return errors.length ? { ok: false, errors } : { ok: true, value: data };
}
 
const reply = '```json\n{"name": "Sam Ortiz", "order_id": null, "issue_type": "late delivery"}\n```';
console.log(parseTicket(reply)); // { ok: false, errors: [ 'issue_type must be one of ...' ] }
import json, re
 
FIELDS = ["name", "order_id", "issue_type"]
ISSUE_TYPES = ["damaged", "late", "wrong-item", "other"]
 
def parse_ticket(reply):
    text = re.sub(r"^```(json)?\s*|\s*```$", "", reply.strip())
    try:
        data = json.loads(text)
    except json.JSONDecodeError:
        return {"ok": False, "errors": ["not valid JSON"]}
    if not isinstance(data, dict):
        return {"ok": False, "errors": ["expected one JSON object"]}
    missing = [f for f in FIELDS if f not in data]
    if missing:
        return {"ok": False, "errors": ["missing " + f for f in missing]}
    errors = []
    if data["name"] is not None and not isinstance(data["name"], str):
        errors.append("name must be a string or null")
    order_id = data["order_id"]
    if order_id is not None and not (isinstance(order_id, str) and re.fullmatch(r"[0-9]+", order_id)):
        errors.append("order_id must be digits or null")
    if data["issue_type"] not in ISSUE_TYPES:
        errors.append("issue_type must be one of " + ", ".join(ISSUE_TYPES))
    return {"ok": False, "errors": errors} if errors else {"ok": True, "value": data}
 
reply = '```json\n{"name": "Sam Ortiz", "order_id": null, "issue_type": "late delivery"}\n```'
print(parse_ticket(reply))  # {'ok': False, 'errors': ['issue_type must be one of ...']}

Missing fields are caught before any value is checked. Note that null and missing are different: "order_id": null is the model saying "not in the email", which you asked for; no order_id key at all means it skipped part of the format.

Designing a shape that works

  • Keep it flat and small. Five clear fields beat twenty nested ones. Each extra field is another chance for a mistake.
  • Name fields plainly. delivery_date tells the model what goes there; dd does not.
  • Give every category field a catch-all. An "other" value gives odd inputs somewhere honest to go.
  • If you need reasoning, give it a field. Put a "reasoning" field before the answer fields, for the reason Give the Model Room to Think explains: the model writes the fields in order, so reasoning placed first can shape the answer fields after it.

Try it yourself

Try it: Make the recipe readable by an app

A shopping-list app needs each ingredient's name, amount and unit, so it can add up amounts across recipes. This prompt returns a nicely formatted list a person can read and the app cannot. Rewrite it.

Show a sample answer
Rewritten promptIllustrative example. Real replies vary.
You
Extract the ingredients from the recipe below for a shopping-list app. Reply with a JSON array and nothing else. Each item: { "name": the ingredient in singular, lowercase, e.g. "egg", "amount": a number, or null if no amount is given (as in "salt to taste"), "unit": one of "g", "ml", "tsp", "tbsp", "piece", or null } Convert "2 cups" and similar to ml (1 cup = 240 ml). Use "piece" for countable items like eggs. <recipe> [recipe text] </recipe>

Why it works: the app can add amounts only if units match, so the prompt fixes a short list of units and says how to convert. null covers "to taste" honestly instead of an invented quantity. Singular lowercase names mean "Eggs" and "egg" from two recipes land on the same line. And "nothing else" keeps the reply parseable, though your code should still check it.

Common mistakes

Check yourself

Quick check

An email has no order number. Your prompt does not mention missing values. What is the risk?

You use an API mode that guarantees replies match your JSON schema. Do you still need to check replies?

You want the model to reason before picking a category, inside the JSON. Where should the reasoning field go?

What to practise next

So far every instruction has lived in a single message. When the same rules must apply to every message in a conversation or an app, they belong in a system prompt.