thisago's blog


OpenAI with strict JSON Schema outputs

Table of Contents

There are many tutorials1 instructing you to use .description in your JSON Schema for structured outputs. But no one is telling this field is ignored. The official doc doesn't includes it in the "supported fields" but uses it all the time, implicitly telling it works.

Let's Take Conclusions

The test I'll be doing is simple:

  • Request a instruction at prompt and a divergent instruction at field description
  • I expect it to follow the property description.

I'll be using restclient.el because its syntax is a explicit and readable HTTP request. No magic behind.

Picking .choices.[0].message.content of the following request2, with strict schema:

:token := (+pass-get-secret "net/openai.com/token/blog")

:system = Start message with 'Hello'.
:schema-description = This MUST start with 'Hey'.
:user = Say hi

POST https://api.openai.com/v1/chat/completions
Content-Type: application/json
Authorization: Bearer :token

{
  "model": "gpt-4.1",
  "messages": [
    {"role": "system", "content": ":system"},
    {"role": "user", "content": ":user"}
  ],
  "response_format": {
  "type": "json_schema",
    "json_schema": {
      "name": "messageSchema",
      "strict": true,
      "schema": {
        "type": "object",
        "additionalProperties": false,
        "required": ["message"],
        "properties": {
          "message": {
            "type": "string",
            "description": ":schema-description"
          }
        }
      }
    }
  },
  "temperature": 0.6
}
"{\"message\":\"Hey! How can I assist you today?\"}"

OpenAI Python SDK Try

It's [2026-01-28 Wed], and I'm not satisfied with these results, so I'll reproduce exactly what I did in my previous implementation.

For it, we'll need to setup deps, I'll be using uv:

[project]
name = "openai-json-schema"
version = "0.0.1"
requires-python = ">=3.13"

And run:

uv venv --allow-existing
uv add openai pydantic

Minimal code trying to reproduce the above raw request:

from json import dumps

from openai import OpenAI
from openai.lib._pydantic import to_strict_json_schema
from openai.types.chat import (
    ChatCompletionSystemMessageParam,
    ChatCompletionUserMessageParam,
)
from pydantic import BaseModel, TypeAdapter


class ResponseModel(BaseModel):
    "This MUST start with 'Hey'."

    message: str


client = OpenAI(api_key=openai_api_key)

system_message: ChatCompletionSystemMessageParam = {
    "role": "system",
    "content": "Start message with 'Hello'.",
}
user_message: ChatCompletionUserMessageParam = {"role": "user", "content": "Say hi"}

completion = client.chat.completions.parse(
    model="gpt-4.1",
    messages=[system_message, user_message],
    temperature=0.6,
    response_format=ResponseModel,
)
choice_message = completion.choices[0].message.parsed

# Completion result
print(choice_message.model_dump_json(indent=2))

# Printing how OpenAI sends the JSON Schema (yes, the SDK changes the schema to make it more strict)
print(dumps(to_strict_json_schema(TypeAdapter(ResponseModel)), ensure_ascii=False, indent=2))
{
  "message": "Hey, hello! How can I help you today?"
}
{
  "description": "This MUST start with 'Hey'.",
  "properties": {
    "message": {
      "title": "Message",
      "type": "string"
    }
  },
  "required": [
    "message"
  ],
  "title": "ResponseModel",
  "type": "object",
  "additionalProperties": false
}

As I can see, there's a difference in how I provided the description: It wasn't for the field, but for the model.

But either way, it also worked now.

I'll be researching more.

Conclusion

OK, I was wrong, it works. I will review the implementation that misled me into this conclusion.

In the end, I hope it can serve as example for how call the completions API it without SDKs.

Footnotes:

1

This was deleted for some reason, also the account. Wayback Machine only archived the profile BTW.

2

You can see the source of this page. Actually the selector is included in the source block header, which is not exported.