{"slug": "guarantee-structured-json-from-any-llm-call-with-openai-structured-outputs-and", "title": "Guarantee Structured JSON from Any LLM Call with OpenAI Structured Outputs and Pydantic", "summary": "OpenAI's structured outputs API, combined with Pydantic, allows developers to receive fully-typed JSON objects from LLM calls, eliminating fragile parsing and schema drift. The tutorial demonstrates extracting structured meeting data using Python, Pydantic models, and the `parse()` method on supported models like GPT-4o.", "body_md": "# Guarantee Structured JSON from Any LLM Call with OpenAI Structured Outputs and Pydantic\n\nSkip the fragile JSON parsing and schema drift. Use OpenAI's structured outputs API with Pydantic to get fully-typed objects back from every model call.\n\n[Rachel Goldstein](https://sourcefeed.dev/u/rachel_goldstein)\n\n## What you'll build\n\nYou'll write a Python script that sends unstructured text to an OpenAI model and gets back a fully-typed Pydantic object on every call. No prompt-engineering tricks, no `json.loads()`\n\nin a try/except, no silent schema drift.\n\n## Prerequisites\n\n- Python 3.10 or later (the\n`str | None`\n\nunion syntax and lowercase generics like`list[str]`\n\nrequire it) `openai >= 1.40.0`\n\n(when the`parse()`\n\nhelper and structured outputs support shipped)`pydantic >= 2.0`\n\n- An OpenAI API key exported as\n`OPENAI_API_KEY`\n\n- A supported model:\n`gpt-4o`\n\n,`gpt-4o-mini`\n\n, or any`gpt-4o-*`\n\nsnapshot dated 2024-08-06 or later\n\nStructured outputs don't work on `gpt-4-turbo`\n\n, `gpt-3.5-turbo`\n\n, or pre-August-2024 model snapshots. Passing those will give you a `BadRequestError`\n\n.\n\n## 1. Install dependencies\n\n```\npip install \"openai>=1.40.0\" \"pydantic>=2.0\"\n```\n\nExport your API key if you haven't already:\n\n```\nexport OPENAI_API_KEY=\"sk-...\"\n```\n\nThe `OpenAI()`\n\nclient picks it up automatically from the environment.\n\n## 2. Define your schema\n\nHere's a realistic example: extracting structured data from meeting notes.\n\n``` python\nfrom pydantic import BaseModel, Field\n\nclass ActionItem(BaseModel):\n    owner: str = Field(description=\"Full name of the person responsible\")\n    task: str = Field(description=\"Specific action to be completed\")\n    due_date: str | None = Field(\n        default=None,\n        description=\"Due date in ISO 8601 format if mentioned, otherwise null\",\n    )\n\nclass MeetingExtract(BaseModel):\n    summary: str = Field(description=\"One-sentence summary of the meeting\")\n    decisions: list[str] = Field(description=\"Key decisions made, as a list\")\n    action_items: list[ActionItem]\n```\n\nTwo things worth being explicit about. First, `Field(description=...)`\n\ndoes real work here; the model reads those descriptions when populating fields, so be precise. Second, use `str`\n\nfor dates, not `datetime`\n\n. OpenAI's strict schema mode maps Pydantic types to JSON Schema primitives and `datetime`\n\ndoesn't have a clean mapping. You'll get a `BadRequestError`\n\nif you try it.\n\n## 3. Call the API\n\nThe key is `client.beta.chat.completions.parse()`\n\n. Pass your Pydantic class as `response_format`\n\nand the SDK generates the JSON Schema, enables strict mode, and deserializes the response into your model.\n\n``` python\nfrom openai import OpenAI\nfrom pydantic import BaseModel, Field\n\nclient = OpenAI()\n\nclass ActionItem(BaseModel):\n    owner: str = Field(description=\"Full name of the person responsible\")\n    task: str = Field(description=\"Specific action to be completed\")\n    due_date: str | None = Field(\n        default=None,\n        description=\"Due date in ISO 8601 format if mentioned, otherwise null\",\n    )\n\nclass MeetingExtract(BaseModel):\n    summary: str = Field(description=\"One-sentence summary of the meeting\")\n    decisions: list[str] = Field(description=\"Key decisions made, as a list\")\n    action_items: list[ActionItem]\n\nNOTES = \"\"\"\nProduct sync, March 12. Attendees: Priya, Dan, Leila.\nDecided to drop IE11 support and push the launch to April 1st.\nDan will update the changelog by March 15. Priya owns browser compat testing,\nno deadline set. Leila to schedule a stakeholder demo next week.\n\"\"\"\n\ncompletion = client.beta.chat.completions.parse(\n    model=\"gpt-4o-mini\",\n    messages=[\n        {\n            \"role\": \"system\",\n            \"content\": \"Extract structured data from the meeting notes provided.\",\n        },\n        {\"role\": \"user\", \"content\": NOTES},\n    ],\n    response_format=MeetingExtract,\n)\n```\n\n## 4. Read the result\n\nThe model can refuse if the request triggers a content policy, and generation can be cut off if the output hits the token limit. Check both before touching `.parsed`\n\n.\n\n```\nmessage = completion.choices[0].message\n\nif message.refusal:\n    raise ValueError(f\"Model refused: {message.refusal}\")\n\nresult = message.parsed\nif result is None:\n    raise ValueError(\n        f\"Failed to parse response. Finish reason: {completion.choices[0].finish_reason}\"\n    )\n\nprint(result.summary)\nfor item in result.action_items:\n    print(f\"  {item.owner}: {item.task} (due: {item.due_date})\")\n```\n\n`message.parsed`\n\nis a live `MeetingExtract`\n\ninstance with real types, not a dict. You get autocomplete, attribute access, and Pydantic validators for free.\n\nExpected output, roughly:\n\n```\nThe team agreed to drop IE11 support and move the launch to April 1st.\n  Dan: Update the changelog (due: 2024-03-15)\n  Priya: Browser compatibility testing (due: None)\n  Leila: Schedule a stakeholder demo (due: None)\n```\n\n## Verify it works\n\nRun a quick sanity check after the call:\n\n```\nassert isinstance(result, MeetingExtract), \"Expected MeetingExtract\"\nassert all(isinstance(i, ActionItem) for i in result.action_items)\nassert isinstance(result.decisions, list)\n\nfinish_reason = completion.choices[0].finish_reason\nassert finish_reason == \"stop\", f\"Unexpected finish_reason: {finish_reason}\"\n\nprint(\"All checks passed.\")\n```\n\nThe `finish_reason`\n\ncheck matters. If the model is cut off mid-generation (value is `\"length\"`\n\n), `message.parsed`\n\nwill be `None`\n\neven with no refusal. The `result is None`\n\nguard in Step 4 catches this before you ever reach the assertions. Set `max_tokens`\n\nhigh enough for your schema's complexity if you're hitting it.\n\n## Troubleshooting\n\n** openai.BadRequestError: Invalid schema**\nYour model uses a type that doesn't map to JSON Schema. Common causes:\n\n`datetime`\n\n, `Decimal`\n\n, `UUID`\n\n, bare `Any`\n\n, or a `dict`\n\nwithout typed values. Swap to `str`\n\n, `float`\n\n, or a concrete nested Pydantic model.** message.parsed is None with no refusal**\nCheck\n\n`finish_reason`\n\n. If it's `\"length\"`\n\n, increase `max_tokens`\n\n. If it's `\"stop\"`\n\nand `parsed`\n\nis still `None`\n\n, the SDK failed to deserialize the content; inspect `message.content`\n\ndirectly to debug the raw JSON.** AttributeError: 'ChatCompletionMessage' has no attribute 'parsed'**\nYou called\n\n`client.chat.completions.create()`\n\ninstead of `client.beta.chat.completions.parse()`\n\n. The standard `create()`\n\nresponse type doesn't include `.parsed`\n\n.** ValidationError on import or at model definition time**\nA Pydantic v1/v2 conflict. Run\n\n`python -c \"import pydantic; print(pydantic.VERSION)\"`\n\nand confirm you're on 2.x. Pin with `pip install \"pydantic>=2.0,<3\"`\n\nif another package dragged in v1.## Next steps\n\n**Raw JSON schema via REST**: If you're not in Python, use`response_format={\"type\": \"json_schema\", \"json_schema\": {\"name\": \"MeetingExtract\", \"strict\": true, \"schema\": {...}}}`\n\nin any HTTP client.**Streaming**:`client.beta.chat.completions.stream()`\n\naccepts the same`response_format`\n\nand fires events as the object builds, useful for progressive UI updates.**Function calling vs. structured outputs**: use structured outputs when you want typed data returned unconditionally. Use tool calling when the model needs to decide whether to invoke a tool at all.**Schema size limits**: OpenAI enforces a maximum of 5 nesting levels and 100 total properties across the entire schema. Flatten aggressively if you hit this ceiling.\n\n[Rachel Goldstein](https://sourcefeed.dev/u/rachel_goldstein)· Dev Tools Editor\n\nRachel has been embedded in the developer tooling ecosystem for nearly eight years, covering everything from IDE wars and package-manager drama to the quiet rise of AI-assisted coding. She has a soft spot for open-source maintainers and an unhealthy number of terminal emulators installed on a single laptop.\n\n## Discussion 0\n\nNo comments yet\n\nBe the first to weigh in.", "url": "https://wpnews.pro/news/guarantee-structured-json-from-any-llm-call-with-openai-structured-outputs-and", "canonical_source": "https://sourcefeed.dev/a/guarantee-structured-json-from-any-llm-call-with-openai-structured-outputs-and-p", "published_at": "2026-07-09 07:35:54+00:00", "updated_at": "2026-07-09 07:45:19.038677+00:00", "lang": "en", "topics": ["artificial-intelligence", "large-language-models", "ai-tools", "developer-tools"], "entities": ["OpenAI", "Pydantic", "GPT-4o", "GPT-4o-mini", "Rachel Goldstein"], "alternates": {"html": "https://wpnews.pro/news/guarantee-structured-json-from-any-llm-call-with-openai-structured-outputs-and", "markdown": "https://wpnews.pro/news/guarantee-structured-json-from-any-llm-call-with-openai-structured-outputs-and.md", "text": "https://wpnews.pro/news/guarantee-structured-json-from-any-llm-call-with-openai-structured-outputs-and.txt", "jsonld": "https://wpnews.pro/news/guarantee-structured-json-from-any-llm-call-with-openai-structured-outputs-and.jsonld"}}