{"id":541,"date":"2026-08-03T13:04:03","date_gmt":"2026-08-03T13:04:03","guid":{"rendered":"https:\/\/abrarqasim.com\/blog\/openai-structured-outputs-json-schema-what-strict-mode-wont-do\/"},"modified":"2026-08-03T13:04:03","modified_gmt":"2026-08-03T13:04:03","slug":"openai-structured-outputs-json-schema-what-strict-mode-wont-do","status":"publish","type":"post","link":"https:\/\/abrarqasim.com\/blog\/openai-structured-outputs-json-schema-what-strict-mode-wont-do\/","title":{"rendered":"OpenAI Structured Outputs JSON Schema: What Strict Mode Won&#8217;t Do"},"content":{"rendered":"<p>Short version for the impatient: <code>strict: true<\/code> guarantees the <em>shape<\/em> of your JSON. It guarantees nothing about whether the values inside it are right. If you already knew that, skip ahead to the bit about optional fields, because that&rsquo;s where I actually lost a day.<\/p>\n<p>Here&rsquo;s the setup. I had an extraction endpoint pulling line items out of scanned supplier invoices. Schema locked down, <code>strict: true<\/code>, Pydantic model on the other end. It had been quiet for weeks. Then finance flagged an invoice where the tax amount came back as <code>0.0<\/code> on a document that clearly showed 5% VAT.<\/p>\n<p>My first instinct was that the API had regressed. It hadn&rsquo;t. The response was perfectly schema-valid. It was just wrong. Constrained decoding had done exactly what it promised and nothing more, and I&rsquo;d quietly started treating &ldquo;parses cleanly&rdquo; as &ldquo;is correct&rdquo; somewhere around week three.<\/p>\n<h2 id=\"strict-mode-is-a-grammar-not-a-fact-checker\">Strict mode is a grammar, not a fact checker<\/h2>\n<p>The mechanism is worth understanding, because it explains precisely which class of bugs this feature kills and which it leaves alone.<\/p>\n<p>OpenAI compiles your JSON Schema into a context-free grammar, then masks the sampler at every step so only tokens valid under that grammar can be emitted. Their <a href=\"https:\/\/openai.com\/index\/introducing-structured-outputs-in-the-api\/\" rel=\"nofollow noopener\" target=\"_blank\">announcement post on Structured Outputs<\/a> walks through this: once the model has produced <code>{\"val<\/code>, an opening brace is no longer a legal next token, so its probability gets zeroed. That&rsquo;s also why the first request against a brand new schema is slow. The grammar has to be preprocessed and cached, and OpenAI says complex schemas can take up to a minute the first time.<\/p>\n<p>So the guarantee is real, and it&rsquo;s a token-level guarantee. Braces balance. Enums stay inside their allowed values. Required keys show up. What no grammar can check is whether <code>5.0<\/code> should have been <code>12.50<\/code>. OpenAI is direct about this in their own limitations section: structured outputs &ldquo;doesn&rsquo;t prevent all kinds of model mistakes,&rdquo; and they specifically call out wrong values inside a well-formed object.<\/p>\n<p>I find it useful to think of it as the difference between a type system and a test suite. Nobody ships a TypeScript codebase and declares it correct because it compiles. Same energy here. I made a similar mistake in the other direction once, stuffing so much context in that the model started confidently misreading it, which I wrote up in <a href=\"https:\/\/abrarqasim.com\/blog\/llm-context-window-anti-hallucination-prompt-backfired\" rel=\"noopener\">the anti-hallucination prompt that backfired on me<\/a>.<\/p>\n<h2 id=\"the-before-and-after-is-genuinely-dramatic\">The before and after is genuinely dramatic<\/h2>\n<p>Credit where it&rsquo;s due. The old pattern was miserable. This is roughly what my invoice extractor looked like in 2023, on JSON mode, which guaranteed valid JSON and absolutely nothing about the schema:<\/p>\n<pre><code class=\"language-python\">import json\n\nSYSTEM = &quot;&quot;&quot;Return ONLY valid JSON matching this shape:\n{&quot;vendor&quot;: str, &quot;total&quot;: float, &quot;tax&quot;: float, &quot;currency&quot;: &quot;AED&quot;|&quot;USD&quot;}\nDo not include markdown fences. Do not explain.&quot;&quot;&quot;\n\ndef extract(text, attempts=4):\n    for i in range(attempts):\n        r = client.chat.completions.create(\n            model=&quot;gpt-4-0613&quot;,\n            response_format={&quot;type&quot;: &quot;json_object&quot;},\n            messages=[\n                {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: SYSTEM},\n                {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: text},\n            ],\n        )\n        raw = r.choices[0].message.content\n        try:\n            data = json.loads(raw)\n        except json.JSONDecodeError:\n            continue\n        # JSON mode gave us valid JSON. It did not give us OUR JSON.\n        if not {&quot;vendor&quot;, &quot;total&quot;, &quot;tax&quot;, &quot;currency&quot;} &lt;= data.keys():\n            continue\n        if data[&quot;currency&quot;] not in (&quot;AED&quot;, &quot;USD&quot;):\n            continue\n        return data\n    raise RuntimeError(f&quot;gave up after {attempts} attempts&quot;)\n<\/code><\/pre>\n<p>Four attempts. Manual key checking. An enum I had to police by hand. And a retry loop that burned tokens every time the model decided <code>\"amount_total\"<\/code> was a nicer key name than <code>\"total\"<\/code>.<\/p>\n<p>The current version:<\/p>\n<pre><code class=\"language-python\">from typing import Literal\nfrom pydantic import BaseModel\n\nclass Invoice(BaseModel):\n    vendor: str\n    total: float\n    tax: float\n    currency: Literal[&quot;AED&quot;, &quot;USD&quot;]\n\ncompletion = client.chat.completions.parse(\n    model=&quot;gpt-4.1&quot;,\n    messages=[\n        {&quot;role&quot;: &quot;system&quot;, &quot;content&quot;: &quot;Extract the invoice fields.&quot;},\n        {&quot;role&quot;: &quot;user&quot;, &quot;content&quot;: text},\n    ],\n    response_format=Invoice,\n)\n\ninvoice = completion.choices[0].message.parsed\n<\/code><\/pre>\n<p>The retry loop is gone. The key checking is gone. The enum policing is gone. That&rsquo;s a real improvement and I&rsquo;m not going to pretend otherwise. The SDK converts the Pydantic model to a schema, sets <code>strict: true<\/code>, and deserializes the response back into a typed object.<\/p>\n<p>What survived, unchanged, is the possibility that <code>tax<\/code> is <code>0.0<\/code> when it should be <code>12.50<\/code>.<\/p>\n<h2 id=\"every-field-is-required-and-thats-where-i-lost-the-day\">Every field is required, and that&rsquo;s where I lost the day<\/h2>\n<p>This is the rule that produced my actual bug, and it&rsquo;s the one I&rsquo;d tattoo on a junior dev if they let me.<\/p>\n<p>In strict mode, every property you declare must appear in <code>required<\/code>. Not &ldquo;should.&rdquo; Must. You also need <code>additionalProperties: false<\/code> on every object in the schema, no exceptions. Microsoft&rsquo;s <a href=\"https:\/\/learn.microsoft.com\/en-us\/azure\/foundry\/openai\/how-to\/structured-outputs\" rel=\"nofollow noopener\" target=\"_blank\">Azure OpenAI structured outputs docs<\/a> spell out both rules, and they mirror OpenAI&rsquo;s own subset exactly.<\/p>\n<p>So how do you express a field that genuinely might not exist on the document? You don&rsquo;t make it optional. You union it with null:<\/p>\n<pre><code class=\"language-json\">{\n  &quot;type&quot;: &quot;object&quot;,\n  &quot;properties&quot;: {\n    &quot;vendor&quot;:   { &quot;type&quot;: &quot;string&quot; },\n    &quot;total&quot;:    { &quot;type&quot;: &quot;number&quot; },\n    &quot;tax&quot;:      { &quot;type&quot;: [&quot;number&quot;, &quot;null&quot;] },\n    &quot;po_number&quot;:{ &quot;type&quot;: [&quot;string&quot;, &quot;null&quot;] }\n  },\n  &quot;required&quot;: [&quot;vendor&quot;, &quot;total&quot;, &quot;tax&quot;, &quot;po_number&quot;],\n  &quot;additionalProperties&quot;: false\n}\n<\/code><\/pre>\n<p>Every key is required. Two of them are allowed to be null. Fine so far.<\/p>\n<p>Now read that schema the way the model reads it. <code>tax<\/code> is a number-or-null and it is mandatory. The model must emit <em>something<\/em> in that slot on every single document. When the tax line is smudged, or written in a layout the model hasn&rsquo;t seen, or expressed as &ldquo;VAT included,&rdquo; the grammar offers exactly two escape hatches: a number, or null. There is no &ldquo;I couldn&rsquo;t tell.&rdquo; So it picks one. And a plausible number often beats null on the model&rsquo;s own priors, because most invoices do have a tax amount.<\/p>\n<p>That&rsquo;s my <code>0.0<\/code>. Not a hallucination in the dramatic sense. A forced choice under a constraint I built.<\/p>\n<p>What fixed it was making &ldquo;unknown&rdquo; a first-class value instead of an absence:<\/p>\n<pre><code class=\"language-python\">from typing import Literal, Union\nfrom pydantic import BaseModel\n\nclass Known(BaseModel):\n    status: Literal[&quot;found&quot;]\n    value: float\n    source_text: str   # verbatim span it read the number from\n\nclass Unknown(BaseModel):\n    status: Literal[&quot;not_found&quot;]\n    reason: str\n\nclass Invoice(BaseModel):\n    vendor: str\n    total: float\n    tax: Union[Known, Unknown]\n<\/code><\/pre>\n<p>Two changes doing the work. <code>not_found<\/code> is now a legal, cheap answer, so the model stops guessing to satisfy the grammar. And <code>source_text<\/code> forces it to quote the span it read the number from, which means I can grep the OCR text for that string and reject the row when it isn&rsquo;t there. Schema compliance I get for free. Groundedness I have to design in.<\/p>\n<p>One caveat: the root object can&rsquo;t be an <code>anyOf<\/code>, so this union pattern works on nested fields, not at the top level.<\/p>\n<h2 id=\"the-keywords-your-schema-is-quietly-ignoring\">The keywords your schema is quietly ignoring<\/h2>\n<p>Here&rsquo;s the part that stings if you came from ordinary JSON Schema validation, where <code>pattern<\/code> and <code>minimum<\/code> actually mean something. In strict mode a large chunk of the vocabulary is accepted and then ignored.<\/p>\n<p>Per the Azure docs, the ignored list includes <code>minLength<\/code>, <code>maxLength<\/code>, <code>pattern<\/code> and <code>format<\/code> on strings; <code>minimum<\/code>, <code>maximum<\/code> and <code>multipleOf<\/code> on numbers; <code>minItems<\/code>, <code>maxItems<\/code> and <code>uniqueItems<\/code> on arrays. Objects lose <code>patternProperties<\/code>, <code>propertyNames<\/code>, <code>minProperties<\/code> and <code>maxProperties<\/code>. There&rsquo;s also a ceiling of 100 object properties total and five levels of nesting. Recursion via <code>$ref<\/code> and shared definitions in <code>$defs<\/code> are both supported, which surprised me the first time I tried it.<\/p>\n<p>Which means this schema is lying to you:<\/p>\n<pre><code class=\"language-json\">{\n  &quot;email&quot;:  { &quot;type&quot;: &quot;string&quot;, &quot;format&quot;: &quot;email&quot; },\n  &quot;iban&quot;:   { &quot;type&quot;: &quot;string&quot;, &quot;pattern&quot;: &quot;^AE[0-9]{21}$&quot; },\n  &quot;rating&quot;: { &quot;type&quot;: &quot;integer&quot;, &quot;minimum&quot;: 1, &quot;maximum&quot;: 5 }\n}\n<\/code><\/pre>\n<p>You&rsquo;ll get a string in <code>email<\/code>. It may not contain an <code>@<\/code>. You&rsquo;ll get an integer in <code>rating<\/code>. It may be <code>47<\/code>.<\/p>\n<p>Two things help. Push what you can into <code>enum<\/code>, which is enforced, so <code>rating<\/code> becomes <code>\"type\": \"integer\", \"enum\": [1,2,3,4,5]<\/code>. Then validate the rest after the fact, in your own code, where the constraint actually runs:<\/p>\n<pre><code class=\"language-python\">from pydantic import BaseModel, EmailStr, Field, field_validator\n\nclass Contact(BaseModel):\n    email: EmailStr\n    iban: str = Field(pattern=r&quot;^AE[0-9]{21}$&quot;)\n\n    @field_validator(&quot;iban&quot;)\n    @classmethod\n    def check_mod97(cls, v: str) -&gt; str:\n        # the model can produce a well-formed IBAN that isn't a real one\n        if not iban_mod97_valid(v):\n            raise ValueError(&quot;checksum failed&quot;)\n        return v\n<\/code><\/pre>\n<p>Note that Pydantic will happily send <code>pattern<\/code> up in the generated schema, where it does nothing, and then enforce it locally on the way back, where it does. That asymmetry caught me out for a while.<\/p>\n<h2 id=\"three-ways-you-still-get-something-that-isnt-your-schema\">Three ways you still get something that isn&rsquo;t your schema<\/h2>\n<p>The grammar holds right up until it doesn&rsquo;t, and there are exactly three documented exits.<\/p>\n<p>The model can refuse. Safety refusals bypass the schema entirely and come back on a separate <code>refusal<\/code> field on the message. If you&rsquo;re reading <code>.parsed<\/code> without checking <code>.refusal<\/code> first, you&rsquo;ll get a <code>None<\/code> and a confusing traceback three functions later.<\/p>\n<p>The generation can hit <code>max_tokens<\/code> mid-object. You get truncated, invalid JSON. Always check <code>finish_reason == \"stop\"<\/code> before you trust the payload, especially on schemas with unbounded arrays, since <code>maxItems<\/code> isn&rsquo;t enforced and nothing stops the model from emitting four hundred line items.<\/p>\n<p>And parallel tool calls break it. Structured outputs aren&rsquo;t compatible with parallel function calling. Set <code>parallel_tool_calls: false<\/code> when you&rsquo;re using <code>strict: true<\/code> on tools.<\/p>\n<pre><code class=\"language-python\">msg = completion.choices[0].message\n\nif msg.refusal:\n    raise ModelRefused(msg.refusal)\nif completion.choices[0].finish_reason != &quot;stop&quot;:\n    raise Truncated(completion.choices[0].finish_reason)\n\ninvoice = msg.parsed\n<\/code><\/pre>\n<p>Nine lines. I&rsquo;ve now added them to every extraction path I own, including the ones in <a href=\"https:\/\/abrarqasim.com\/work\" rel=\"noopener\">the document pipelines I build for clients<\/a>.<\/p>\n<h2 id=\"what-id-do-this-week\">What I&rsquo;d do this week<\/h2>\n<p>Pick your highest-volume structured call and open its schema. Find every field typed as <code>[\"something\", \"null\"]<\/code> and ask what the model does when it can&rsquo;t tell. If null is the only alternative to a value, you&rsquo;re pushing it toward a guess. Add an explicit <code>not_found<\/code> branch and a <code>source_text<\/code> field, then sample fifty recent responses and check how often that quoted span actually appears in your input. My hit rate was 91%. I&rsquo;d assumed 100%, which was the whole problem.<\/p>\n<p>The parsing problem is solved. I&rsquo;d started acting like the extraction problem was solved too, and those aren&rsquo;t the same thing. Same lesson I keep relearning every time a framework makes something easy, most recently when <a href=\"https:\/\/abrarqasim.com\/blog\/ai-sdk-7-the-agent-loop-i-finally-deleted\" rel=\"noopener\">AI SDK 7 let me delete my own agent loop<\/a> and I briefly forgot I still had to think about what the loop was doing.<\/p>\n<p>If you want the full list of what the subset does and doesn&rsquo;t accept, <a href=\"https:\/\/platform.openai.com\/docs\/guides\/structured-outputs\" rel=\"nofollow noopener\" target=\"_blank\">OpenAI&rsquo;s structured outputs guide<\/a> is the reference, and it&rsquo;s worth twenty minutes before you design a schema rather than after.<\/p>\n","protected":false},"excerpt":{"rendered":"<p>Strict mode guarantees your JSON matches the schema, not that the values are right. The optional-field trap that cost me a day, and the checks I added after.<\/p>\n","protected":false},"author":2,"featured_media":540,"comment_status":"","ping_status":"open","sticky":false,"template":"","format":"standard","meta":{"rank_math_title":"","rank_math_description":"Strict mode guarantees your JSON matches the schema, not that the values are right. The optional-field trap that cost me a day, and the checks I added after.","rank_math_focus_keyword":"openai structured outputs json schema","rank_math_canonical_url":"","rank_math_robots":"","footnotes":""},"categories":[4,45],"tags":[613,612,609,610,106,611,608],"class_list":["post-541","post","type-post","status-publish","format-standard","has-post-thumbnail","hentry","category-ai","category-programming","tag-constrained-decoding","tag-data-extraction","tag-json-schema","tag-llm-api","tag-openai","tag-pydantic","tag-structured-outputs"],"_links":{"self":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/541","targetHints":{"allow":["GET"]}}],"collection":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts"}],"about":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/types\/post"}],"author":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/users\/2"}],"replies":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/comments?post=541"}],"version-history":[{"count":0,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/posts\/541\/revisions"}],"wp:featuredmedia":[{"embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media\/540"}],"wp:attachment":[{"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/media?parent=541"}],"wp:term":[{"taxonomy":"category","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/categories?post=541"},{"taxonomy":"post_tag","embeddable":true,"href":"https:\/\/abrarqasim.com\/blog\/wp-json\/wp\/v2\/tags?post=541"}],"curies":[{"name":"wp","href":"https:\/\/api.w.org\/{rel}","templated":true}]}}