Introduction
Azure OpenAI’s structured outputs feature gives you 100% guaranteed JSON schema compliance from the model. Unlike JSON mode, which asks the model to return valid JSON but allows it to invent fields or skip required ones, structured outputs enforce your schema strictly — the model will never return a response that deviates from it.
This guide covers three approaches to structured outputs in C#:
- SDK direct —
ChatResponseFormat.CreateJsonSchemaFormat<T>in the OpenAI .NET SDK - MEAI —
Microsoft.Extensions.AIwithChatOptions.ResponseFormat - Semantic Kernel —
OpenAIPromptExecutionSettings.ResponseFormat = typeof(T)
It also covers the schema design rules that determine which C# types work, how to use [Description] attributes to guide the model, error handling for schema violations, and how to validate your schema locally before deploying.
1. Structured Outputs vs JSON Mode
Understanding the difference matters because the two features behave differently under failure conditions.
JSON mode (response_format: json_object) tells the model to return syntactically valid JSON. It will return some JSON. It may include extra fields, omit required ones, use wrong value types, or invent property names. Your deserialization code must defensively handle all of these.
Structured outputs (response_format: json_schema with strict: true) send your schema to the model and enforce it at the API level. The model cannot return a response that deviates from the schema. If the model cannot produce a valid response, the API returns an error rather than a schema-violating response.
// JSON mode — what you asked for vs what you might get
// Schema: { "title": string, "score": int, "tags": string[] }
// Actual response:
{
"title": "Example Article",
"rating": 8, // Wrong field name
"keywords": ["AI"] // Wrong field name
}
// Structured outputs — schema is enforced
// You always get:
{
"title": "Example Article",
"score": 8,
"tags": ["AI"]
}
Structured outputs require:
- GPT-4o (model version
2024-08-06or later) or GPT-4o-mini - API version
2024-08-01-previewor later
For the v2 SDK types used throughout this guide, see the Azure OpenAI SDK v2 Migration Guide for C# if you are upgrading from an older SDK version.
2. Approach 1: SDK Direct with ChatResponseFormat
The most explicit approach uses ChatResponseFormat.CreateJsonSchemaFormat<T> directly in ChatCompletionOptions. This gives you the most control and works with any version of the SDK without additional frameworks.
Setting Up the Client
using Azure;
using Azure.AI.OpenAI;
using OpenAI.Chat;
// Structured outputs require API version 2024-08-01-preview or later
var clientOptions = new AzureOpenAIClientOptions(
AzureOpenAIClientOptions.ServiceVersion.V2024_08_01_Preview);
var azureClient = new AzureOpenAIClient(
new Uri("https://<your-resource>.openai.azure.com/"),
new AzureKeyCredential(Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY")!),
clientOptions);
ChatClient chatClient = azureClient.GetChatClient("gpt-4o-deployment");
Defining the Schema Type
Define a C# record or class using only JSON Schema-compatible types (covered in section 5):
using System.ComponentModel;
public class ArticleAnalysis
{
public string Title { get; set; } = string.Empty;
public string Summary { get; set; } = string.Empty;
public string Sentiment { get; set; } = string.Empty; // "positive", "neutral", "negative"
public string[] KeyTopics { get; set; } = [];
public int WordCount { get; set; }
[Description("ISO 8601 date string, e.g. 2026-03-21")]
public string PublishedDate { get; set; } = string.Empty;
}
Making the Request
using System.Text.Json;
using OpenAI.Chat;
var options = new ChatCompletionOptions
{
ResponseFormat = ChatResponseFormat.CreateJsonSchemaFormat<ArticleAnalysis>(
"article_analysis", // Schema name — snake_case, no spaces
strict: true) // Enables guaranteed schema compliance
};
List<ChatMessage> messages =
[
new SystemChatMessage("You are a precise content analysis assistant. Analyze the provided article and return a structured analysis."),
new UserChatMessage($"Analyze this article: {articleText}")
];
ClientResult<ChatCompletion> result = await chatClient.CompleteChatAsync(messages, options);
// Access the JSON from the response
string json = result.Value.Content[0].Text;
// Deserialize — guaranteed to succeed if the API call succeeded
ArticleAnalysis analysis = JsonSerializer.Deserialize<ArticleAnalysis>(json)!;
Console.WriteLine($"Title: {analysis.Title}");
Console.WriteLine($"Sentiment: {analysis.Sentiment}");
Console.WriteLine($"Key topics: {string.Join(", ", analysis.KeyTopics)}");
The key points:
strict: trueis required for schema compliance guarantees; without it, behaviour falls back to best-effort JSON- Access the response text via
result.Value.Content[0].Text— note.Valueto unwrapClientResult<ChatCompletion> JsonSerializer.Deserialize<T>will always succeed if the API call completed without error, because the schema is enforced server-side
Schema Name Convention
The first argument to CreateJsonSchemaFormat is the schema name. Use snake_case, no spaces, no special characters:
// ✅ Valid schema names
ChatResponseFormat.CreateJsonSchemaFormat<ArticleAnalysis>("article_analysis", strict: true)
ChatResponseFormat.CreateJsonSchemaFormat<ProductRecord>("product_record", strict: true)
ChatResponseFormat.CreateJsonSchemaFormat<OrderSummary>("order_summary", strict: true)
// ❌ Invalid — will cause a 400 error
ChatResponseFormat.CreateJsonSchemaFormat<ArticleAnalysis>("Article Analysis", strict: true) // spaces
ChatResponseFormat.CreateJsonSchemaFormat<ArticleAnalysis>("article-analysis", strict: true) // hyphens
3. Approach 2: Microsoft.Extensions.AI (MEAI)
Microsoft.Extensions.AI provides a unified IChatClient abstraction over multiple providers. It supports requesting JSON output via ChatOptions.ResponseFormat, but does not expose a typed structured output API in version 10.3.0. You use it with JSON mode and manual deserialization.
using Microsoft.Extensions.AI;
// Register IChatClient via DI (typically in Program.cs)
// builder.Services.AddAzureOpenAIChatClient(...);
public class ArticleService(IChatClient chatClient)
{
public async Task<ArticleAnalysis> AnalyzeAsync(string articleText)
{
var options = new ChatOptions
{
ResponseFormat = ChatResponseFormat.Json // JSON mode, not strict schema
};
// Include the schema description in the system prompt for guidance
var messages = new List<ChatMessage>
{
new(ChatRole.System,
"""
You are a content analysis assistant. Return a JSON object with these fields:
- title (string): article title
- summary (string): 2-3 sentence summary
- sentiment (string): "positive", "neutral", or "negative"
- keyTopics (string[]): main topics covered
- wordCount (int): estimated word count
- publishedDate (string): ISO 8601 date if found, otherwise empty string
"""),
new(ChatRole.User, $"Analyze this article: {articleText}")
};
ChatResponse response = await chatClient.GetResponseAsync(messages, options);
// MEAI does not guarantee schema compliance — deserialize defensively
string json = response.Text;
return JsonSerializer.Deserialize<ArticleAnalysis>(json)
?? throw new InvalidOperationException("Model returned invalid JSON");
}
}
When to use MEAI:
- Your application already uses
IChatClientabstractions for portability across providers - You need to switch between Azure OpenAI and other providers without changing calling code
- Schema compliance is important but you handle deviations in application logic
For strict schema guarantees with MEAI-registered clients, use the SDK-direct approach or Semantic Kernel instead.
4. Approach 3: Semantic Kernel
Semantic Kernel’s OpenAI connector has first-class structured output support. Setting ResponseFormat = typeof(T) in OpenAIPromptExecutionSettings automatically configures the API call with strict: true.
Setup
<PackageReference Include="Microsoft.SemanticKernel" Version="1.54.0" />
<PackageReference Include="Microsoft.SemanticKernel.Connectors.AzureOpenAI" Version="1.54.0" />
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.AzureOpenAI;
var builder = Kernel.CreateBuilder();
builder.AddAzureOpenAIChatCompletion(
deploymentName: "gpt-4o-deployment",
endpoint: "https://<your-resource>.openai.azure.com/",
apiKey: Environment.GetEnvironmentVariable("AZURE_OPENAI_KEY")!);
Kernel kernel = builder.Build();
Invoking with Structured Output
using System.Text.Json;
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.OpenAI;
// Set ResponseFormat to the target type — SK handles the rest
var settings = new OpenAIPromptExecutionSettings
{
ResponseFormat = typeof(ArticleAnalysis)
};
var kernelArgs = new KernelArguments(settings);
FunctionResult result = await kernel.InvokePromptAsync(
$"Analyze this article and return a structured analysis: {articleText}",
kernelArgs);
// Get the raw JSON string from the result
string json = result.GetValue<string>()!;
// Deserialize
ArticleAnalysis analysis = JsonSerializer.Deserialize<ArticleAnalysis>(json)!;
Using Semantic Kernel with Template Functions
For more complex scenarios involving prompt templates:
using Microsoft.SemanticKernel;
using Microsoft.SemanticKernel.Connectors.OpenAI;
var analysisFunction = kernel.CreateFunctionFromPrompt(
"Analyze the following article content and extract structured information:\n\n{{$input}}",
new OpenAIPromptExecutionSettings
{
ResponseFormat = typeof(ArticleAnalysis),
MaxTokens = 1000
});
KernelArguments args = new()
{
["input"] = articleText
};
FunctionResult result = await kernel.InvokeAsync(analysisFunction, args);
string json = result.GetValue<string>()!;
ArticleAnalysis analysis = JsonSerializer.Deserialize<ArticleAnalysis>(json)!;
The SK approach is the most concise when you are already using Semantic Kernel in your application. The connector handles the schema generation and API configuration transparently.
5. Schema Design Rules
Azure OpenAI’s structured output mode accepts a subset of JSON Schema. Understanding the supported and unsupported types prevents 400 errors before they reach production.
Supported Types
| C# Type | JSON Schema Type | Notes |
|---|---|---|
string | string | Fully supported |
int, long | integer | Fully supported |
bool | boolean | Fully supported |
decimal, double, float | number | Fully supported |
string[], List<string> | array of string | Fully supported |
int[], List<int> | array of integer | Fully supported |
Nested class/record | object | Fully supported |
enum | string with enum values | Supported |
Nullable (string?, int?) | anyOf: [{type}, {type: null}] | Supported |
Unsupported Types
| C# Type | Problem | Fix |
|---|---|---|
DateTime | Not a JSON Schema primitive | Use string with [Description] |
DateTimeOffset | Not a JSON Schema primitive | Use string with [Description] |
Uri | Schema generator may emit non-primitive metadata | Use string with [Description] |
TimeSpan | No JSON Schema equivalent | Use string with [Description] |
Guid | May emit format metadata | Use string |
byte[] | Complex schema | Use string (Base64) with [Description] |
Working Example
using System.ComponentModel;
// ❌ Wrong — contains unsupported types
public class InvoiceRecord
{
public string InvoiceNumber { get; set; } = string.Empty;
public DateTime IssuedAt { get; set; } // ❌ Not supported
public Uri PaymentUrl { get; set; } = null!; // ❌ Not supported
public TimeSpan PaymentTerms { get; set; } // ❌ Not supported
public decimal TotalAmount { get; set; }
public string[] LineItems { get; set; } = [];
}
// ✅ Correct — all JSON Schema primitives
public class InvoiceRecord
{
public string InvoiceNumber { get; set; } = string.Empty;
[Description("ISO 8601 date-time string, e.g. 2026-03-21T09:00:00Z")]
public string IssuedAt { get; set; } = string.Empty;
[Description("Full URL for the payment portal")]
public string PaymentUrl { get; set; } = string.Empty;
[Description("Payment terms as a human-readable string, e.g. 'Net 30 days'")]
public string PaymentTerms { get; set; } = string.Empty;
public decimal TotalAmount { get; set; }
public string[] LineItems { get; set; } = [];
}
Nested Objects and Arrays
Nested objects and arrays of objects are fully supported:
public class OrderAnalysis
{
public string OrderId { get; set; } = string.Empty;
public string Status { get; set; } = string.Empty; // "pending", "shipped", "delivered"
public LineItem[] Items { get; set; } = [];
public ShippingInfo Shipping { get; set; } = new();
public decimal TotalAmount { get; set; }
}
public class LineItem
{
public string ProductName { get; set; } = string.Empty;
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
public class ShippingInfo
{
public string Carrier { get; set; } = string.Empty;
public string TrackingNumber { get; set; } = string.Empty;
[Description("Estimated delivery date in ISO 8601 format, e.g. 2026-03-25")]
public string EstimatedDelivery { get; set; } = string.Empty;
}
6. Using the [Description] Attribute
The [Description] attribute from System.ComponentModel is included in the generated JSON schema as a description field. The model reads this at inference time and uses it to format output correctly.
This is particularly important for string properties that represent typed data (dates, URLs, enums, formatted values):
using System.ComponentModel;
public class ContentMetadata
{
public string Title { get; set; } = string.Empty;
[Description("2-3 sentence summary, written in third person")]
public string Summary { get; set; } = string.Empty;
[Description("One of: 'beginner', 'intermediate', 'advanced'")]
public string Level { get; set; } = string.Empty;
[Description("ISO 8601 date string only (no time), e.g. 2026-03-21")]
public string PublishedDate { get; set; } = string.Empty;
[Description("Estimated reading time as a string, e.g. '8 min read'")]
public string ReadingTime { get; set; } = string.Empty;
[Description("Comma-separated list of .NET SDK names and versions referenced, e.g. 'Azure.AI.OpenAI 2.1.0, OpenAI 2.1.0'")]
public string SdkVersions { get; set; } = string.Empty;
[Description("URL slug derived from the title, kebab-case, no special characters")]
public string Slug { get; set; } = string.Empty;
}
After deserialization, parse the string values back to their proper .NET types in a mapping layer:
// Deserialize the model response
ContentMetadata meta = JsonSerializer.Deserialize<ContentMetadata>(json)!;
// Map to your domain model — parse strings to proper types here
var article = new Article
{
Title = meta.Title,
Summary = meta.Summary,
PublishedDate = DateOnly.Parse(meta.PublishedDate), // Parse string → DateOnly
ReadingTime = meta.ReadingTime,
Slug = meta.Slug
};
7. Error Handling
Schema-related errors arrive as 400 invalid_request_error responses. There are three distinct root causes, each with a specific fix.
For a complete diagnostic guide with exact error messages and fixes, see Fix Azure OpenAI Structured Output JSON Schema Errors in C#.
The pattern for handling schema errors with a fallback to JSON mode:
using Azure;
using OpenAI.Chat;
using System.Text.Json;
public async Task<ArticleAnalysis?> AnalyzeWithFallbackAsync(
ChatClient chatClient,
string articleText)
{
// Try structured outputs first
try
{
var options = new ChatCompletionOptions
{
ResponseFormat = ChatResponseFormat.CreateJsonSchemaFormat<ArticleAnalysis>(
"article_analysis",
strict: true)
};
ClientResult<ChatCompletion> result = await chatClient.CompleteChatAsync(
[new UserChatMessage($"Analyze this article: {articleText}")],
options);
return JsonSerializer.Deserialize<ArticleAnalysis>(result.Value.Content[0].Text);
}
catch (RequestFailedException ex) when (ex.Status == 400)
{
// Log the schema error for investigation
Console.Error.WriteLine($"Structured output schema error: {ex.Message}");
// Fall back to JSON mode — no schema guarantee but more permissive
try
{
var fallbackOptions = new ChatCompletionOptions
{
ResponseFormat = ChatResponseFormat.CreateJsonObjectFormat()
};
ClientResult<ChatCompletion> fallbackResult = await chatClient.CompleteChatAsync(
[
new SystemChatMessage(
"Return a JSON object with: title (string), summary (string), " +
"sentiment (string), keyTopics (string[]), wordCount (int), publishedDate (string)"),
new UserChatMessage($"Analyze this article: {articleText}")
],
fallbackOptions);
string json = fallbackResult.Value.Content[0].Text;
return JsonSerializer.Deserialize<ArticleAnalysis>(json);
}
catch (JsonException jsonEx)
{
Console.Error.WriteLine($"JSON fallback deserialization failed: {jsonEx.Message}");
return null;
}
}
}
Key error scenarios:
| Error | Root Cause | Fix |
|---|---|---|
response_format.json_schema is not supported for this API version | Wrong API version | Set ServiceVersion.V2024_08_01_Preview or later |
Invalid schema for response_format 'json_schema': unsupported type | DateTime, Uri, or TimeSpan in schema | Replace with string + [Description] |
Structured outputs are not supported with the current model | GPT-4 Turbo or non-OpenAI model | Switch to GPT-4o (2024-08-06+) or use JSON mode |
8. Testing and Validating Your Schema
Before deploying a new structured output type to production, validate the schema it generates using JsonSchemaExporter from System.Text.Json.Schema (.NET 9+):
using System.Text.Json;
using System.Text.Json.Schema;
// Generate the schema your type will produce
JsonNode schema = JsonSchemaExporter.GetJsonSchemaAsNode(
new JsonSerializerOptions(),
typeof(ArticleAnalysis));
// Pretty-print for inspection
string schemaJson = schema.ToJsonString(new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(schemaJson);
Example output for ArticleAnalysis:
{
"type": "object",
"properties": {
"title": { "type": "string" },
"summary": { "type": "string" },
"sentiment": { "type": "string" },
"keyTopics": {
"type": "array",
"items": { "type": "string" }
},
"wordCount": { "type": "integer" },
"publishedDate": {
"type": "string",
"description": "ISO 8601 date string, e.g. 2026-03-21"
}
}
}
What to look for in the output:
- Any
$refentries — these indicate complex types that may not be supported - Any
formatannotations — these are often produced byDateTimeand can cause 400 errors - Any property with a non-primitive
type—objectwithinanyOforoneOfmay require additional testing
Add a unit test or integration test that generates and inspects the schema for every type used with structured outputs:
[Fact]
public void ArticleAnalysis_SchemaContainsOnlySupportedTypes()
{
var options = new JsonSerializerOptions();
JsonNode schema = JsonSchemaExporter.GetJsonSchemaAsNode(options, typeof(ArticleAnalysis));
string schemaJson = schema.ToJsonString();
// Verify no unsupported types leaked into the schema
Assert.DoesNotContain("\"format\"", schemaJson); // DateTime format annotations
Assert.DoesNotContain("\"$ref\"", schemaJson); // Complex type references
Assert.DoesNotContain("\"anyUri\"", schemaJson); // URI type hints
}
This test will catch type issues at build time rather than at runtime when the API rejects the schema.
Summary
Structured outputs in Azure OpenAI give you the reliability of a strongly-typed API response without building fragile parsing logic. The right approach depends on your stack:
- SDK direct (
ChatResponseFormat.CreateJsonSchemaFormat<T>) — explicit, portable, works anywhere you have anAzureOpenAIClient - MEAI — use for provider portability; note it uses JSON mode, not strict schemas, in version 10.3.0
- Semantic Kernel (
ResponseFormat = typeof(T)) — most concise when SK is already in your stack
Schema design is where most failures occur. Keep types simple: only JSON Schema primitives, replace DateTime/Uri/TimeSpan with string, and use [Description] attributes to communicate format expectations. Validate with JsonSchemaExporter before every deployment.