Designing JSON Schemas for LLM Outputs: Best Practices
A well-designed schema is the foundation of reliable structured output. Poor schema design leads to truncated responses, hallucinated fields, or LLMs struggling to fit their output into your constraints. This guide teaches you the patterns and anti-patterns that separate production-grade schemas from those that fail in the wild.
Core Schema Design Principles
1. Field Names Should Be Explicit, Not Clever
Use descriptive, unambiguous names that match the semantics of what you're extracting. The LLM learns the intent from field names.
Bad:
{
"properties": {
"s": {"type": "string"},
"v": {"type": "number"},
"c": {"type": "boolean"}
}
}
Good:
{
"properties": {
"sentiment": {"type": "string", "enum": ["positive", "negative", "neutral"]},
"confidence_score": {"type": "number", "minimum": 0, "maximum": 1},
"contains_urgency": {"type": "boolean"}
}
}
The LLM uses field names as semantic cues. Explicit names reduce hallucination and misalignment.
2. Use Enums to Eliminate Ambiguity
When a field has a fixed set of valid values, declare them as an enum. This forces the LLM to choose one of your options instead of inventing variations.
Bad (LLM guesses):
{
"properties": {
"product_type": {"type": "string"}
}
}
Downstream code must handle "electronics", "electronic", "tech", "gadget", etc.
Good (enforced classification):
{
"properties": {
"product_type": {
"type": "string",
"enum": ["electronics", "clothing", "food", "home", "sports", "other"]
}
}
}
3. Mark Fields as Required, Not Optional by Default
Only mark a field as optional (not in required) if the LLM might genuinely have no data for it. For most extraction tasks, all fields should be required.
If you're tempted to make a field optional because "sometimes the data isn't there," instead ask: should the LLM infer a sensible default, or should the field be required with an explicit "none" or "unknown" value?
Anti-pattern (too permissive):
{
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
"phone": {"type": "string"}
},
"required": ["name"]
}
Code must defensively check for missing email and phone.
Better (all required):
{
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"},
"phone": {"type": "string"}
},
"required": ["name", "email", "phone"]
}
If data is missing, the LLM returns "unknown" or "", which is explicit and easier to handle.
4. Add Constraints to Prevent Hallucination
Use JSON Schema constraints (minLength, maxLength, minimum, maximum, pattern, minItems, maxItems) to guide the LLM and prevent verbose or malformed output.
Example: Constrain a summary field to prevent verbose hallucinations
{
"type": "object",
"properties": {
"summary": {
"type": "string",
"minLength": 10,
"maxLength": 200,
"description": "One-sentence summary of the main topic"
}
},
"required": ["summary"]
}
The LLM will respect the maxLength constraint and produce concise output instead of multi-paragraph text.
5. Use description Field Liberally
JSON Schema's description field is underutilized. Descriptions are visible to the LLM during generation and significantly improve output quality.
{
"type": "object",
"properties": {
"sentiment": {
"type": "string",
"enum": ["positive", "negative", "neutral"],
"description": "Overall sentiment: 'positive' if the text expresses approval or satisfaction, 'negative' for disapproval, 'neutral' for factual or mixed content"
},
"confidence": {
"type": "number",
"minimum": 0,
"maximum": 1,
"description": "Confidence score from 0 to 1. Use 0.9+ for clear sentiment, 0.5–0.7 for mixed signals, 0.3 or below for ambiguous text"
}
}
}
6. Avoid Deeply Nested Structures When Possible
Each level of nesting increases cognitive load and error rates. Flatten where you can; use dot-notation field names.
Unnecessarily nested:
{
"type": "object",
"properties": {
"author": {
"type": "object",
"properties": {
"name": {"type": "string"},
"email": {"type": "string"}
}
},
"post": {
"type": "object",
"properties": {
"title": {"type": "string"},
"content": {"type": "string"}
}
}
}
}
Flatter (more explicit):
{
"type": "object",
"properties": {
"author_name": {"type": "string"},
"author_email": {"type": "string"},
"post_title": {"type": "string"},
"post_content": {"type": "string"}
}
}
When nesting is necessary (e.g., arrays of objects), see Handling Nested Objects and Arrays.
Schema Pattern Library
Pattern 1: Classification Task
{
"type": "object",
"properties": {
"primary_category": {
"type": "string",
"enum": ["bug", "feature", "question", "documentation", "other"]
},
"severity": {
"type": "string",
"enum": ["critical", "high", "medium", "low"]
},
"tags": {
"type": "array",
"items": {"type": "string", "enum": ["api", "ui", "performance", "security"]},
"max_items": 5
}
},
"required": ["primary_category", "severity"]
}
Pattern 2: Extraction Task
{
"type": "object",
"properties": {
"extracted_entities": {
"type": "array",
"items": {
"type": "object",
"properties": {
"entity_name": {"type": "string"},
"entity_type": {"type": "string", "enum": ["person", "company", "location", "date"]},
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
},
"required": ["entity_name", "entity_type"]
}
}
},
"required": ["extracted_entities"]
}
Pattern 3: Multi-Step Reasoning
{
"type": "object",
"properties": {
"problem_statement": {"type": "string", "maxLength": 300},
"reasoning_steps": {
"type": "array",
"items": {"type": "string"},
"min_items": 2,
"max_items": 10
},
"final_answer": {"type": "string"},
"confidence": {"type": "number", "minimum": 0, "maximum": 1}
},
"required": ["problem_statement", "reasoning_steps", "final_answer"]
}
Testing Your Schema
Before deploying, test your schema with real prompts:
import json
from openai import OpenAI
client = OpenAI()
schema = {
# Your schema here
}
test_prompts = [
"Test case 1: a simple example",
"Test case 2: an edge case",
"Test case 3: ambiguous input"
]
for prompt in test_prompts:
response = client.chat.completions.create(
model="gpt-4-turbo",
messages=[{"role": "user", "content": prompt}],
response_format={
"type": "json_schema",
"json_schema": {"name": "TestSchema", "schema": schema, "strict": True}
}
)
result = json.loads(response.choices[0].message.content)
print(f"Input: {prompt}\nOutput: {json.dumps(result, indent=2)}\n")
Key Takeaways
- Field names should be explicit and semantically meaningful; the LLM learns intent from names.
- Use enums to lock in valid values and eliminate hallucination of variations.
- Mark fields as required unless there's a genuine reason they might be missing.
- Add constraints (
maxLength,minItems, enum values) to prevent verbose or malformed output. - Use
descriptionfields liberally; LLMs read them during generation and adjust behavior accordingly. - Keep nesting shallow; flatten multi-level objects into dot-notation field names.
- Test your schema with diverse prompts before production deployment.
Frequently Asked Questions
Should I add additionalProperties: false to prevent extra fields?
Yes. In strict mode, additionalProperties: false enforces that the response contains only declared fields, no surprises. Include it unless you have a reason to allow arbitrary fields.
{
"type": "object",
"properties": {...},
"required": [...],
"additionalProperties": false
}
What if the LLM keeps adding extra fields that aren't in my schema?
This suggests your required array is incomplete. Fields the LLM thinks are important should be listed in properties and required. Alternatively, ensure you've set additionalProperties: false to prevent surprises.
Can I have a field that accepts multiple types (string or number)?
Yes, using oneOf. But this is rare and adds ambiguity. Usually, you should pick one type and constrain it.
{
"type": "object",
"properties": {
"flexible_field": {
"oneOf": [
{"type": "string"},
{"type": "number"}
]
}
}
}
How do I handle null or missing values?
Explicitly. Define a field as "type": "string" or "type": "null" if it should allow null, or design enums to include an "unknown" or "none" option.
{
"properties": {
"status": {"type": "string", "enum": ["active", "inactive", "unknown"]}
}
}
Should I use pattern (regex) constraints?
Only for strict formats (email, UUID, phone numbers). General patterns are fragile and the LLM may struggle to satisfy them. Use enum or length constraints instead.