Structured outputs let you ask a large language model for JSON that matches a schema you define, so your C# code receives a typed object instead of free text it has to parse with regular expressions. This guide is for .NET developers who call LLMs from production code and need classification labels, extracted fields or decisions they can trust. You will learn how JSON mode differs from schema-constrained decoding, how GetResponseAsync<T> in Microsoft.Extensions.AI works, how to generate JSON Schema from C# types, what strict mode can and cannot enforce, and how to build validation, retries, refusal handling and tests around it.
What Are Structured Outputs?#
A chat model produces tokens, not objects. When you need a sentiment label, a list of invoice lines or a routing decision, something has to turn those tokens into data. There are four common approaches, and they differ sharply in reliability:
- Prompt-only JSON. You describe the shape in the prompt and hope. Models often add Markdown fences, commentary or invented field names.
- JSON mode. The service guarantees syntactically valid JSON, but not any particular shape. Missing properties and wrong types still happen.
- Structured outputs with a JSON Schema. You send a schema with the request and the service uses it to constrain generation, so the output matches the schema's structure.
- Tool (function) calling. The model returns arguments for a function described by a schema. The mechanics are similar, but the intent is "run this operation", not "here is my answer".
Structured outputs are the right default when the model's answer is data. They turn schema compliance from a probabilistic property of your prompt into a property of the decoding process. A well-formed object can still hold a wrong classification, but an entire class of parsing failures disappears and the remaining failures become easier to detect. For the tool-calling side of the same idea, see Function Calling and Tool Use with LLMs in C#.
How Structured Outputs Work Under the Hood#
Constrained decoding versus JSON mode#
With JSON mode, the service only keeps the token stream valid JSON. With structured outputs, the provider compiles your schema into a grammar and, at every generation step, masks tokens that would violate it. If your schema says category is one of six strings, the model cannot emit a seventh. That is why providers recommend schema-based structured outputs over JSON mode wherever a model supports them.
Two consequences follow. First, grammar compilation costs time: Anthropic documents extra latency on the first request with a new schema and caches compiled grammars for 24 hours, so keep schemas stable instead of generating a new variant per request. Second, the model writes JSON from left to right, so property order is the order in which it commits to values. A rationale property placed before category lets the model reason before deciding; placed after it, the rationale becomes a post-hoc justification.
What Microsoft.Extensions.AI does for you#
Microsoft.Extensions.AI exposes response formats through ChatOptions.ResponseFormat, which accepts ChatResponseFormat.Text, ChatResponseFormat.Json (JSON mode) or a schema created with ChatResponseFormat.ForJsonSchema. The GetResponseAsync<T> extension methods then do the plumbing:
- They generate a JSON Schema from
TwithAIJsonUtilities, including descriptions from[Description]attributes. - If
Tis not an object type, such as an enum, string or array, they wrap the schema in an object with one requireddataproperty, because providers expect an object at the root. The wrapper is removed when the result is read. - They set the schema as the response format by default. With
useJsonSchemaResponseFormat: false, they switch to JSON mode and embed the schema in an instruction message instead, which helps with models that reject native schemas. - They return a
ChatResponse<T>: a normalChatResponsewith messages, usage and finish reason, plus deserialization helpers.
ChatResponse<T>.Result deserializes lazily and throws when the text contains no JSON, when deserialization yields null or when the data wrapper is missing. TryGetResult returns false instead, which is what production code wants. The library targets .NET 8, 9 and 10, .NET Standard 2.0 and .NET Framework 4.6.2, and ships independently of the runtime, so you do not need to wait for .NET 11 to get new features.
Getting Started: Typed Responses in Microsoft.Extensions.AI#
Install the abstractions and a provider adapter. This example uses Azure OpenAI with Microsoft Entra ID authentication, but the calling code is identical for any IChatClient.
dotnet add package Microsoft.Extensions.AI
dotnet add package Microsoft.Extensions.AI.OpenAI
dotnet add package Azure.AI.OpenAI
dotnet add package Azure.IdentityThe type argument is the contract. The model sees a schema derived from ReviewAnalysis, and your code receives an instance of it.
using System.ComponentModel;
using Azure.AI.OpenAI;
using Azure.Identity;
using Microsoft.Extensions.AI;
var endpoint = Environment.GetEnvironmentVariable("AZURE_OPENAI_ENDPOINT")
?? throw new InvalidOperationException("Set AZURE_OPENAI_ENDPOINT.");
IChatClient client = new AzureOpenAIClient(new Uri(endpoint), new DefaultAzureCredential())
.GetChatClient("gpt-5-mini")
.AsIChatClient();
var review = "Setup took five minutes, but the battery died after two days.";
ChatResponse<ReviewAnalysis> response =
await client.GetResponseAsync<ReviewAnalysis>($"Analyze this product review: {review}");
if (response.TryGetResult(out var analysis))
{
Console.WriteLine($"{analysis.Sentiment} ({analysis.Confidence:P0}): {analysis.Summary}");
}
else
{
Console.WriteLine($"Unparseable output: {response.Text}");
}
public enum Sentiment { Positive, Negative, Mixed, Neutral }
[Description("Structured analysis of a single product review.")]
public sealed record ReviewAnalysis(
[property: Description("One neutral sentence summarizing the review.")] string Summary,
Sentiment Sentiment,
[property: Description("Confidence in the sentiment, from 0 to 1.")] double Confidence);ChatResponse<T> inherits from ChatResponse, so Usage, FinishReason and ModelId remain available for logging and cost tracking. The default serializer options (AIJsonUtilities.DefaultOptions) use camelCase names, case-insensitive reading and string enums, so the schema lists "Positive" or "Mixed" as allowed values instead of asking the model to remember that 2 means Mixed.
Designing C# Types for Structured Outputs#
Your C# type is now a prompt as much as a data contract. The model reads property names, descriptions and enum values, so design them for a reader with no access to your codebase.
using System.ComponentModel;
using System.ComponentModel.DataAnnotations;
public enum TicketCategory { Billing, Outage, Bug, FeatureRequest, Account, Other }
public enum Priority { Low, Normal, High, Urgent }
[Description("Triage decision for one customer support ticket.")]
public sealed record TicketTriage(
[property: Description("One or two sentences of reasoning, written before deciding.")]
string Rationale,
TicketCategory Category,
Priority Priority,
[property: Description("Product area named by the customer, or null if none is named.")]
string? ProductArea,
[property: Range(0.0, 1.0)]
[property: Description("Confidence in the category, from 0 to 1.")]
double Confidence);A few rules of thumb make a large difference:
- Use enums for closed sets and always include an escape hatch. A classifier without
OtherorUnknownmust pick a wrong label when the input does not fit. The escape hatch also gives you a clean signal for human review. - Use nullable types for facts that may be absent.
string? ProductAreaproduces a schema that allowsnull, so the model can say "not present" instead of inventing a value. - Describe units, formats and edge cases in
[Description]. "Amount in the invoice currency, without symbols" prevents a surprising number of errors. - Prefer flat, shallow records. Deep nesting, dictionaries and polymorphic hierarchies map poorly onto strict schemas. A
Dictionary<string, string>needs open-ended properties, which strict mode forbids. - Target properties with
[property: ...]in positional records. Without the target, attributes apply only to the constructor parameter, and validators that inspect properties will not see them.
Strict Mode and Its Limitations#
"Structured outputs" covers a spectrum. At one end, the schema is guidance that steers the model but still allows deviation. At the other, strict mode makes the service enforce the schema through constrained decoding. How you opt in depends on the provider and adapter, and this is where many .NET teams get surprised.
In the Microsoft.Extensions.AI OpenAI adapter (both the Chat Completions and Responses clients), strictness is opt-in. The adapter always rewrites your schema into a strict-compatible shape, requiring every property and disallowing extra ones, but it asks the service to enforce the schema only when ChatOptions.AdditionalProperties contains strict set to true.
var options = new ChatOptions
{
MaxOutputTokens = 1_000,
AdditionalProperties = new() { ["strict"] = true },
};
ChatResponse<TicketTriage> response = await client.GetResponseAsync<TicketTriage>(
messages, options, cancellationToken: cancellationToken);Strict mode brings constraints that shape your types:
- Every property is required. Express optionality as a nullable type, which becomes a union with
null, not as an omitted property. - No additional properties. Dictionaries and open-ended objects are out.
- The root must be an object. That is why
GetResponseAsync<T>wraps enums, primitives and arrays in adataproperty. - Validation keywords are not enforced. Azure OpenAI lists
minLength,maxLength,pattern,format,minimum,maximum,minItemsanduniqueItems, among others, as unsupported. The OpenAI adapter removes such keywords and appends them to the description, so your[Range(0.0, 1.0)]becomes a hint the model reads, not a rule the decoder enforces. - Size limits apply. Azure OpenAI documents a limit of 100 object properties in total and five levels of nesting. Split large extraction schemas into several calls.
- Model support varies. Structured outputs are tied to specific model and API versions. Check your deployment, and keep
useJsonSchemaResponseFormat: falseas a fallback for local or older models.
Other providers follow the same pattern with different parameter names. Claude accepts a JSON Schema through output_config.format, supports only a subset of JSON Schema (no recursive schemas and no numeric or string length constraints) and signals refusals with a refusal stop reason. Ollama accepts either json or a full JSON Schema in its format parameter. Microsoft.Extensions.AI adapters translate ChatOptions.ResponseFormat into these shapes, so your application code stays the same.
Validation, Repair and Retry Loops#
Even with strict decoding, you need two layers of checking. Structural validation asks whether the text parses into your type, which TryGetResult answers. Semantic validation asks whether the values make sense: confidence within range, totals that add up, dates in the past, a category consistent with the rationale. Schemas cannot express most semantic rules, and some structural keywords are only hints.
A bounded repair loop handles both by feeding validation errors back to the model and asking for a corrected object. Two design choices matter. Never retry blindly on truncation: if FinishReason is Length, the same request will be cut off again. And cap attempts, because an unbounded loop turns a model regression into a cost incident.
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.AI;
public sealed class StructuredOutputException(string message) : Exception(message);
public static class StructuredChat
{
public static async Task<T> GetValidatedAsync<T>(
this IChatClient client,
IList<ChatMessage> messages,
ChatOptions? options = null,
int maxAttempts = 3,
CancellationToken cancellationToken = default)
where T : class
{
for (var attempt = 1; ; attempt++)
{
ChatResponse<T> response = await client.GetResponseAsync<T>(
messages, options, cancellationToken: cancellationToken);
if (response.FinishReason == ChatFinishReason.Length)
{
throw new StructuredOutputException("Output was truncated; raise MaxOutputTokens.");
}
List<string> problems = [];
if (response.TryGetResult(out var result))
{
List<ValidationResult> results = [];
var valid = Validator.TryValidateObject(
result, new ValidationContext(result), results, validateAllProperties: true);
if (valid)
{
return result;
}
problems.AddRange(results.Select(r => r.ErrorMessage ?? "A value is invalid."));
}
else
{
problems.Add("The reply was not valid JSON for the requested schema.");
}
var details = string.Join("; ", problems);
if (attempt >= maxAttempts)
{
throw new StructuredOutputException(
$"No valid {typeof(T).Name} after {attempt} attempts: {details}");
}
messages.AddMessages(response);
messages.Add(new ChatMessage(ChatRole.User,
$"Your previous answer had these problems: {details}. " +
"Return a corrected JSON object that fixes them and changes nothing else."));
}
}
}Keep the repair message short and include the validator's messages rather than a generic "try again". If the same validation keeps failing, the task definition or schema is usually at fault, not sampling noise, and your evaluation suite should reveal it, as described in Evaluating AI Applications in .NET.
Handling Refusals, Content Filters and Truncation#
A response can arrive successfully and still contain no usable object. Handle these outcomes explicitly instead of letting them surface as deserialization errors:
- Refusals. When a model declines a request on safety grounds, it cannot produce the schema. OpenAI returns a separate refusal string, which the Microsoft.Extensions.AI OpenAI adapter surfaces as an
ErrorContentitem whoseErrorCodeis"Refusal". Claude reports arefusalstop reason. - Content filtering. Azure OpenAI and other services may stop generation because of content filters, reported as
ChatFinishReason.ContentFilter. - Truncation. When the output limit is hit, you get
ChatFinishReason.Lengthand, almost certainly, incomplete JSON.
Model these outcomes as a closed set so callers must handle each one:
using Microsoft.Extensions.AI;
public abstract record StructuredResult<T>
{
public sealed record Success(T Value) : StructuredResult<T>;
public sealed record Refused(string Reason) : StructuredResult<T>;
public sealed record Filtered : StructuredResult<T>;
public sealed record Truncated : StructuredResult<T>;
public sealed record Invalid(string RawText) : StructuredResult<T>;
}
public static class StructuredResults
{
public static StructuredResult<T> From<T>(ChatResponse<T> response)
{
var refusal = response.Messages
.SelectMany(m => m.Contents)
.OfType<ErrorContent>()
.FirstOrDefault(e => e.ErrorCode == "Refusal");
if (refusal is not null)
return new StructuredResult<T>.Refused(refusal.Message);
if (response.FinishReason == ChatFinishReason.ContentFilter)
return new StructuredResult<T>.Filtered();
if (response.FinishReason == ChatFinishReason.Length)
return new StructuredResult<T>.Truncated();
return response.TryGetResult(out var value)
? new StructuredResult<T>.Success(value)
: new StructuredResult<T>.Invalid(response.Text);
}
}Callers pattern-match and decide what each case means for the product: a polite message for a refusal, a human queue for filtered items, a configuration bug report for truncation. Do not feed refusals into the repair loop, because asking the model to "fix" a refusal amounts to asking it to override its safety behavior.
Building Classification and Extraction Pipelines#
Most structured output workloads fall into two families. Classification maps input to a fixed label set: ticket triage, intent detection, moderation queues. Extraction pulls fields out of unstructured text: invoices, contracts, CVs, incident reports. Both benefit from the same shape: a stable schema, a focused system prompt, bounded concurrency and post-processing that applies business rules the model cannot be trusted with.
using System.Collections.Concurrent;
using System.ComponentModel;
using Microsoft.Extensions.AI;
public sealed record InvoiceLine(string Description, decimal Quantity, decimal UnitPrice);
[Description("Fields extracted from one invoice. Use null for anything not present.")]
public sealed record InvoiceExtraction(
string? InvoiceNumber,
DateOnly? InvoiceDate,
string? VendorName,
[property: Description("ISO 4217 currency code, for example EUR.")] string Currency,
IReadOnlyList<InvoiceLine> Lines,
decimal? Total);
public sealed class InvoicePipeline(IChatClient client)
{
private const string Instructions =
"Extract invoice fields from the document. Copy values exactly as written. " +
"Never compute or guess missing values; use null instead.";
public async Task<IReadOnlyList<(string Id, InvoiceExtraction? Data)>> RunAsync(
IEnumerable<(string Id, string Text)> documents, CancellationToken cancellationToken)
{
var results = new ConcurrentBag<(string, InvoiceExtraction?)>();
var parallel = new ParallelOptions
{
MaxDegreeOfParallelism = 4,
CancellationToken = cancellationToken,
};
await Parallel.ForEachAsync(documents, parallel, async (doc, token) =>
{
List<ChatMessage> messages =
[
new ChatMessage(ChatRole.System, Instructions),
new ChatMessage(ChatRole.User, doc.Text),
];
var response = await client.GetResponseAsync<InvoiceExtraction>(
messages, cancellationToken: token);
var data = response.TryGetResult(out var invoice) && LinesMatchTotal(invoice)
? invoice
: null; // null means "send to manual review"
results.Add((doc.Id, data));
});
return [.. results];
}
private static bool LinesMatchTotal(InvoiceExtraction invoice) =>
invoice.Total is not { } total
|| Math.Abs(invoice.Lines.Sum(l => l.Quantity * l.UnitPrice) - total) < 0.01m;
}The instructions forbid computing values, because arithmetic is a common source of confident errors; the line-item cross-check runs in C#, where it is exact. Concurrency is capped because a burst of parallel calls produces HTTP 429 responses rather than throughput. Anything that fails parsing or business rules goes to human review, which is usually cheaper than elaborate automatic recovery.
For classification, keep label sets small and mutually exclusive, and describe each label in the system prompt with an example or two, as covered in Prompt Engineering for .NET Developers. For long documents, extract per chunk and merge in code; one huge schema over a huge input hits size limits and degrades accuracy at the same time.
Testing Structured Output Code#
Typed outputs make LLM code far easier to test. Test three things separately: your handling logic with a fake client, the schema contract with a snapshot, and real model quality with evaluations. A fake IChatClient returns scripted JSON so you can test retries, refusals and edge cases deterministically:
using Microsoft.Extensions.AI;
public sealed class FakeChatClient(params string[] replies) : IChatClient
{
private readonly Queue<string> _replies = new(replies);
public List<ChatOptions?> Calls { get; } = [];
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default)
{
Calls.Add(options);
var reply = new ChatMessage(ChatRole.Assistant, _replies.Dequeue());
return Task.FromResult(new ChatResponse(reply) { FinishReason = ChatFinishReason.Stop });
}
public IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options = null,
CancellationToken cancellationToken = default) => throw new NotSupportedException();
public object? GetService(Type serviceType, object? serviceKey = null) => null;
public void Dispose() { }
}With xUnit, the tests read like specifications. Non-object results travel inside the data wrapper, so a scripted enum reply must use it:
using System.Text.Json.Nodes;
using Microsoft.Extensions.AI;
using Xunit;
public class TriageTests
{
[Fact]
public async Task Repairs_an_out_of_range_confidence_once()
{
var invalid = """
{"rationale": "Charged twice.", "category": "Billing", "priority": "High",
"productArea": null, "confidence": 7}
""";
var valid = """
{"rationale": "Charged twice.", "category": "Billing", "priority": "High",
"productArea": null, "confidence": 0.9}
""";
var client = new FakeChatClient(invalid, valid);
var triage = await client.GetValidatedAsync<TicketTriage>(
[new ChatMessage(ChatRole.User, "I was charged twice this month.")], maxAttempts: 2);
Assert.Equal(TicketCategory.Billing, triage.Category);
Assert.Equal(2, client.Calls.Count);
}
[Fact]
public async Task Enum_replies_are_unwrapped_from_the_data_property()
{
var client = new FakeChatClient("""{"data":"Negative"}""");
var response = await client.GetResponseAsync<Sentiment>("Worst support call ever.");
Assert.Equal(Sentiment.Negative, response.Result);
}
[Fact]
public void Triage_schema_matches_the_approved_contract()
{
var format = ChatResponseFormat.ForJsonSchema<TicketTriage>();
var actual = JsonNode.Parse(format.Schema!.Value.GetRawText());
var approved = JsonNode.Parse(File.ReadAllText("Schemas/ticket-triage.json"));
Assert.True(JsonNode.DeepEquals(approved, actual),
"Schema changed; review and re-approve it.");
}
}The snapshot test is cheap insurance: renaming a property or changing an enum silently changes what the model sees, which can shift accuracy. Treat the approved schema file like a public API. For general test structure and mocking guidance, see Unit Testing in .NET.
Best Practices#
- Prefer schema-based structured outputs over JSON mode. JSON mode only guarantees syntax; keep it as a fallback for models without schema support.
- Opt in to strict mode deliberately. With the OpenAI adapter, set
AdditionalProperties["strict"] = trueand design types that satisfy strict rules. - Order properties intentionally. A short rationale before decision fields helps accuracy; drop it when latency and output tokens matter more.
- Include an escape-hatch label such as
OtherorUnknownin every classification enum. - Validate semantics in C#. Ranges, totals and cross-field rules belong in code.
- Bound every retry loop, skip retries on truncation and refusals, and log every repair.
- Keep schemas stable and versioned so provider caches and snapshot tests work in your favor.
Common Pitfalls#
- Assuming
GetResponseAsync<T>guarantees a result.Resultcan still throw; useTryGetResultor the outcome pattern above. - Forgetting the enum converter outside Microsoft.Extensions.AI. Default System.Text.Json options describe enums as integers, which models handle poorly.
- Expecting
[Range]or[RegularExpression]to be enforced by the model. With strict OpenAI schemas they become description text. - Dates in unexpected formats.
DateOnlyneeds ISO dates, and "March 3rd" fails deserialization. Describe formats explicitly, or accept a string and parse it yourself. - Markdown fences around JSON.
ChatResponse<T>does not strip them. Fenced output usually means the model ignored the requested format. - Parsing mid-stream. Partial JSON is not parseable. Accumulate streamed updates with
ToChatResponseAsyncand deserialize at the end.
JSON Mode vs Structured Outputs vs Tool Calling#
| Aspect | JSON mode | Structured outputs (schema) | Tool calling |
|---|---|---|---|
| Guarantee | Valid JSON syntax only | Output matches schema structure when enforced | Arguments match the tool's parameter schema |
| M.E.AI API | ChatResponseFormat.Json | GetResponseAsync<T> or ChatResponseFormat.ForJsonSchema | AIFunctionFactory.Create plus ChatOptions.Tools |
| Best for | Models without schema support | Final answers that are data | Letting the model trigger operations |
| Typical failure | Missing or extra fields | Wrong but well-formed values, refusals | Wrong tool choice, invalid arguments |
| Validation needed | Structural and semantic | Semantic, plus keywords that are only hints | Semantic, plus authorization of the action |
Provider support differs in the details:
| Provider | Native schema parameter | Refusal signal | Notes |
|---|---|---|---|
| OpenAI and Azure OpenAI | JSON Schema response format with optional strict | Separate refusal text | Azure lists 100 properties and 5 nesting levels as limits |
| Anthropic Claude | output_config.format with a JSON Schema | refusal stop reason | Compiled grammars are cached for 24 hours |
| Ollama | format set to json or a JSON Schema | Not applicable | Handy for local development |
Frequently Asked Questions#
Does GetResponseAsync with a type argument guarantee valid output?#
No. It generates a schema and asks the provider to use it, which greatly improves reliability, but enforcement depends on the model and on strict mode. The response can also be a refusal, a filtered result or a truncated object, so use TryGetResult, check FinishReason and validate values.
Should I use JSON mode or structured outputs?#
Use structured outputs whenever the model supports them, because JSON mode guarantees syntax, not shape. For models that reject native schemas, pass useJsonSchemaResponseFormat: false to fall back to JSON mode with the schema embedded in the prompt.
How do I make a property optional in strict mode?#
Strict schemas require every property, so express optionality as nullability. Declare the property as string? or DateOnly? so the schema allows null, and say in the description when null is expected.
Can I stream structured output?#
You can stream tokens, but System.Text.Json cannot reliably deserialize partial JSON. Stream for progress indicators, combine updates with ToChatResponseAsync and deserialize once. If users need incremental results, split the task into smaller structured calls.
Does this work on .NET 8?#
Yes. Microsoft.Extensions.AI supports .NET 8, 9 and 10, .NET Standard 2.0 and .NET Framework 4.6.2. JsonSchemaExporter is built into .NET 9 and later and is available on .NET 8 through the System.Text.Json NuGet package.
Summary#
- Structured outputs constrain generation to a JSON Schema; JSON mode only guarantees syntax.
GetResponseAsync<T>derives the schema from your type, wraps non-object types indataand returnsChatResponse<T>withResultandTryGetResult.- Your C# type is part of the prompt: use escape-hatch enums, nullable fields, descriptions and deliberate property order.
- Strict mode is opt-in with the OpenAI adapter and cannot enforce validation keywords, so validate semantics in C#.
- Use bounded repair loops, handle refusals and truncation explicitly, and test with fakes, schema snapshots and evaluations.