Getting Reliable JSON Out of LLMs in C#: Structured Output That Doesn't Break

Chatbots are the demo; data extraction is the workhorse. Most of the LLM value I've shipped in real systems looks nothing like chat: parse this emailed purchase order into line items, classify this support ticket, extract entities from this contract, turn this free-text address into fields. All of these need the model to return data your code can deserialize — and the naive approach ("please respond in JSON") produces the flakiest integration you'll ever maintain: markdown-fenced JSON, trailing commentary ("Here's your JSON!"), invented fields, missing ones.

Modern APIs solve this properly. Here's the reliable pattern in C#.

Structured Outputs: schema-enforced JSON

OpenAI's Structured Outputs (and equivalents elsewhere) constrain the model's generation so the response must conform to a JSON schema you supply — not "usually", but grammatically enforced during token generation:

csharp
using OpenAI.Chat;
using System.Text.Json;

var client = new ChatClient("gpt-4o-mini", apiKey);

var options = new ChatCompletionOptions
{
    ResponseFormat = ChatResponseFormat.CreateJsonSchemaFormat(
        jsonSchemaFormatName: "ticket_triage",
        jsonSchema: BinaryData.FromString("""
        {
            "type": "object",
            "properties": {
                "category": {
                    "type": "string",
                    "enum": ["billing", "technical", "account", "other"]
                },
                "priority": {
                    "type": "string",
                    "enum": ["low", "medium", "high", "urgent"]
                },
                "summary": { "type": "string" },
                "customerSentiment": {
                    "type": "string",
                    "enum": ["positive", "neutral", "negative"]
                }
            },
            "required": ["category", "priority", "summary", "customerSentiment"],
            "additionalProperties": false
        }
        """),
        jsonSchemaIsStrict: true)
};

ChatCompletion completion = await client.CompleteChatAsync(
    [
        new SystemChatMessage("Triage the customer support ticket."),
        new UserChatMessage(ticketText),
    ],
    options);

var triage = JsonSerializer.Deserialize<TicketTriage>(
    completion.Content[0].Text,
    JsonSerializerOptions.Web)!;

With a matching C# record:

csharp
public record TicketTriage(
    string Category,
    string Priority,
    string Summary,
    string CustomerSentiment);

Notes that save debugging time:

  • strict: true + additionalProperties: false + everything in required is the combination that gets you the hard guarantee. Strict mode demands it.
  • Enums are your best friend. "category": "billing" from a closed set beats free text you'd have to normalize. Push every classification field into an enum.
  • The shape is guaranteed — the content is still a model's judgment. "priority": "urgent" will always be a valid enum value; whether it's the right call is a quality question (that's what evaluation sets are for).

Keeping schema and C# types in sync

Hand-writing schemas that mirror your records is a drift bug waiting to happen. Generate the schema from the type — .NET has this built in now:

csharp
using System.Text.Json.Schema;

JsonNode schema = JsonSerializerOptions.Web.GetJsonSchemaAsNode(
    typeof(TicketTriage),
    new JsonSchemaExporterOptions { TreatNullObliviousAsNonNullable = true });

Or skip the plumbing entirely with Microsoft.Extensions.AI, which turns the whole pattern into one generic call:

csharp
using Microsoft.Extensions.AI;

ChatResponse<TicketTriage> response =
    await chatClient.GetResponseAsync<TicketTriage>(
        $"Triage this support ticket:\n{ticketText}");

TicketTriage triage = response.Result; // typed, done

One generic parameter: schema generated from the record, sent as structured output, response deserialized. This is what I use in application code today.

The real-world example: document extraction

Here's the shape of the feature that pays the bills — pulling structured line items from messy human text:

csharp
public record PurchaseOrder(
    string? PoNumber,
    string CustomerName,
    DateOnly? RequestedDelivery,
    List<OrderLine> Lines);

public record OrderLine(string ProductDescription, int Quantity, decimal? UnitPrice);

var result = await chatClient.GetResponseAsync<PurchaseOrder>(
    $"""
    Extract the purchase order details from this email.
    Use null for anything not present. Do not invent values.

    Email:
    {emailBody}
    """);

Design lessons learned doing this on real documents:

  • Make uncertainty representable. Nullable fields plus the instruction "use null for anything not present" gives the model an honest out. A schema with only required non-null fields forces hallucination when data is missing — the model must put something there.
  • Validate business rules after deserialization. Schema enforcement guarantees types, not sense: quantity −3 or a delivery date in 1987 can still arrive. Run the result through the same validation you'd apply to any API input (FluentValidation slots in perfectly).
  • Keep a confidence escape hatch. For high-stakes extraction, add an "extractionIssues": string[] field to the schema and prompt the model to note anything ambiguous — then route non-empty ones to human review. This one field turned a risky automation into an accepted one at a previous employer.

The pipeline that makes it production-grade

Text in → typed object out is only the happy path. Wrap it:

  1. Deserialize (should never fail with strict mode — but guard anyway; treat failure as a retryable error).
  2. Validate business rules; reject or route to review on failure.
  3. Log input, output, token usage, and validation verdicts — you'll need this history the first time someone asks "why did it extract that?"
  4. Evaluate on a growing test set of real inputs with known-correct outputs. When you change the prompt, schema, or model, the test set tells you whether you got better or worse. Prompt changes are code changes; treat them with the same seriousness.

The takeaway

Stop parsing model prose. With schema-enforced structured output, an LLM becomes a well-typed function: string → TicketTriage, string → PurchaseOrder — a component you can compose, validate, and test like anything else in your codebase. It's the single most production-ready pattern in applied AI right now, and C# is genuinely one of the best languages to do it in.

Where to go next