Structured Outputs with Claude: JSON Schema, C# Records and Validation
To tie Claude's answer to a schema you pass a JSON Schema in output_config.format; you generate it from a C# record with the System.Text.Json exporter, add additionalProperties: false, and parse the answer after checking stop_reason. The SDK's Create<T> constraint, nullable fields in the schema, enum casing, refusal and max_tokens, and a Bookstore return request that matches the schema but breaks a business rule.
Version note: The code in this post was built with .NET 10 (System.Text.Json 10), Anthropic C# SDK 13.8.0 and Claude API version 2026-10-21, and its unit tests were run. The JSON Schema outputs in this post were generated locally; the extraction tests use the SDK's real client with a fake HttpMessageHandler that never touches the network. Structured outputs behavior and limits were taken on 15 November 2026 from the platform.claude.com documentation. This post is based on the official documentation.
Guess First
Gül from customer support wants to simplify the return form of Bookstore, the online bookstore used as the shared example across this site's series: the customer writes the problem in free text, and the system turns it into a draft made of an order number, a return reason, the books and an urgency. The classifier in the third post returned a single label, and the result was lost when the model wrote a sentence. This time Emre ties the answer to a JSON schema. In testing every answer matches the schema. Then Alan writes a return request for Dune in Ada's order 1042, and the system turns it into a tidy draft.
What is your guess? Is an answer that matches the schema a correct answer? And if the customer never writes the order number, what goes into the required order_id field of the schema? The provider-neutral logic of structured output and its use through GetResponseAsync<T> was covered in the tenth post of the LLM Fundamentals series, and the design of extraction prompts in the second post of the Prompt Engineering series. In this post we look at the Claude API's output_config.format parameter, generating a schema from C# types, and the mistakes the schema cannot catch.
output_config.format: Tying the Answer to a Schema
The structured outputs page describes two complementary features. JSON outputs (output_config.format) tie the model's answer to a schema, and strict tool use (strict: true) ties the input of tool calls. We used the second one in the fifth post; this post is about the first. The two can also be used together in the same request.
The mechanism is the same: constrained decoding. The API compiles the schema into a grammar, and the model can only produce tokens that fit that grammar. According to the documentation, the result is always JSON that parses, with every required field and the right types; the JSON arrives in the text block of the response. In the request the schema goes in output_config.format with type: "json_schema". Because effort lives in the same output_config object, the two sit side by side.
Watch out for an old parameter. Structured outputs were in beta for a while, and the parameter was called output_format. According to the documentation that parameter is now deprecated; using it requires the structured-outputs-2025-11-13 beta header, and without the header it returns a 400. If you see output_format in older samples, move it to output_config.format.
Generating JSON Schema from a C# Type
The C# counterpart of the draft is a record:
using System.ComponentModel;
namespace Bookstore.Api.Returns;
public enum Urgency
{
Low,
Normal,
High,
}
public sealed record ReturnItem(
[property: Description("Book title as the customer wrote it.")] string Title,
[property: Description("Number of copies to return.")] int Quantity);
public sealed record ReturnRequestDraft(
[property: Description("Order number if the customer gave one, otherwise null.")] string? OrderId,
ReturnReason Reason,
IReadOnlyList<ReturnItem> Items,
Urgency Urgency,
[property: Description("One sentence in English for the support team.")] string Summary);
Instead of writing the schema by hand, we want to generate it from this type, so that the schema changes when the type changes. In .NET, System.Text.Json's JsonSchemaExporter does this. In the first attempt we generated a schema with JsonSerializerDefaults.Web, the settings ASP.NET Core uses:
JsonSerializerOptions web = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() },
TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
};
Console.WriteLine(web.GetJsonSchemaAsNode(typeof(ReturnItem)));
{
"type": [
"object",
"null"
],
"properties": {
"title": {
"type": "string"
},
"quantity": {
"type": [
"string",
"integer"
],
"pattern": "^-?(?:0|[1-9]\\d*)$"
}
},
"required": [
"title",
"quantity"
]
}
This output is a problem for Claude in two ways. quantity can be either a string or an integer, because the Web defaults allow numbers to be read from strings and the exporter honestly writes that into the schema; that is a union type, and the number of parameters with unions is limited in strict schemas. More importantly, the objects have no additionalProperties: false; according to the documentation structured outputs require it on every object and return a 400 for any other value. The root type being ["object", "null"] is not what we want either.
So we wrote a small helper that wraps the exporter with our own settings:
using System.ComponentModel;
using System.Text.Json;
using System.Text.Json.Nodes;
using System.Text.Json.Schema;
using System.Text.Json.Serialization;
using System.Text.Json.Serialization.Metadata;
namespace Bookstore.Api.Ai;
// Builds a JSON Schema from a C# type with System.Text.Json and adapts it to Claude's structured outputs subset.
public static class ClaudeJsonSchema
{
// Not JsonSerializerDefaults.Web: that allows numbers as strings, and the schema would say so.
public static readonly JsonSerializerOptions SerializerOptions = new()
{
PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) },
RespectNullableAnnotations = true,
TypeInfoResolver = new DefaultJsonTypeInfoResolver(),
};
private static readonly JsonSchemaExporterOptions ExporterOptions = new()
{
TreatNullObliviousAsNonNullable = true,
TransformSchemaNode = (context, schema) =>
{
if (schema is not JsonObject node)
{
return schema;
}
// Claude requires additionalProperties: false on every object.
if (IsObjectSchema(node))
{
node["additionalProperties"] = false;
}
// [Description] on a property becomes the schema's description; the model reads it.
DescriptionAttribute? description = context.PropertyInfo?.AttributeProvider?
.GetCustomAttributes(typeof(DescriptionAttribute), inherit: true)
.OfType<DescriptionAttribute>()
.FirstOrDefault();
if (description is not null)
{
node["description"] = description.Description;
}
return node;
},
};
public static JsonObject For<T>() =>
(JsonObject)SerializerOptions.GetJsonSchemaAsNode(typeof(T), ExporterOptions);
public static Dictionary<string, JsonElement> AsSchemaDictionary<T>() =>
For<T>().ToDictionary(kv => kv.Key, kv => JsonSerializer.SerializeToElement(kv.Value));
private static bool IsObjectSchema(JsonObject node) => node["type"] switch
{
JsonValue value => value.GetValue<string>() == "object",
JsonArray types => types.Any(t => t?.GetValue<string>() == "object"),
_ => false,
};
}
Three settings do the work. We do not use the Web defaults, so numbers are only numbers. TreatNullObliviousAsNonNullable closes reference types without a nullable annotation to null. TransformSchemaNode adds additionalProperties: false to every object and moves the [Description] attribute on a property into the schema's description field; the model reads these descriptions. Names and enum values are snake_case, because the same SerializerOptions is used for deserializing as well. The result:
{
"type": "object",
"properties": {
"order_id": {
"type": [
"string",
"null"
],
"description": "Order number if the customer gave one, otherwise null."
},
"reason": {
"enum": [
"damaged",
"wrong_item",
"not_as_described",
"changed_mind",
"late_delivery",
"other"
]
},
"items": {
"type": "array",
"items": {
"type": "object",
"properties": {
"title": {
"type": "string",
"description": "Book title as the customer wrote it."
},
"quantity": {
"type": "integer",
"description": "Number of copies to return."
}
},
"required": [
"title",
"quantity"
],
"additionalProperties": false
}
},
"urgency": {
"enum": [
"low",
"normal",
"high"
]
},
"summary": {
"type": "string",
"description": "One sentence in English for the support team."
}
},
"required": [
"order_id",
"reason",
"items",
"urgency",
"summary"
],
"additionalProperties": false
}
OrderId is string? on the C# side, so in the schema it is ["string", "null"]. Here is the answer to the second question in Guess First: the field is required but can be null, and the description says "null if the customer did not give one". If the field could not be null, the model would have to produce an order number to fit the schema.
The SDK's Own Helper
The C# SDK has a way too. The C# sample on the structured outputs page passes a type to a generic Create<T>() overload; the SDK derives a schema from the type, sends it as output_config.format and parses each text block into the type:
StructuredMessage<ReturnRequestDraft> response = await claude.Messages.Create<ReturnRequestDraft>(new MessageCreateParams
{
Model = Model.ClaudeHaiku5_5,
MaxTokens = 2048,
Messages = [new UserMessageParam { Content = text }],
});
When we compiled this with our own record, the compiler said:
error CS0310: 'ReturnRequestDraft' must be a non-abstract type with a public parameterless constructor in order to use it as parameter 'T' in the generic type or method 'MessageServiceExtensions.Create<T>(IMessageService, MessageCreateParams, CancellationToken)'
In SDK 13.8.0, Create<T> requires a parameterless constructor for T. That is why the sample in the documentation uses a record with { get; set; } properties; our positional record does not meet the constraint. The SDK's schema method (StructuredOutput.ToJsonSchema<T>()) does work with the same record. The start of its output:
StructuredOutput.ToJsonSchema<ReturnRequestDraft>()
{
"type": "object",
"properties": {
"orderId": {
"type": "string"
},
"reason": {
"enum": [
"damaged",
"wrong_item",
"not_as_described",
"changed_mind",
"late_delivery",
"other"
]
},
...
},
"required": [
"orderId",
"reason",
"items",
"urgency",
"summary"
],
"additionalProperties": false
}
There is an important difference here: for OrderId, which is string?, the SDK did not add null to the schema, and it did not carry the [Description] texts. With this schema the model has to write a string into orderId even when the customer gave no number. This behavior may change between SDK versions; what matters is to see and test the schema you send. In Bookstore we use our own exporter: the serializer settings match the rest of the API, the schema is pinned in a test, and nullable fields behave the way we want.
Bookstore: ReturnRequestExtractor
We build the schema once and use it on every request:
using System.Text.Json;
using Anthropic;
using Anthropic.Models.Messages;
using Bookstore.Api.Ai;
using Microsoft.Extensions.Options;
namespace Bookstore.Api.Returns;
public sealed record ExtractionResult(ReturnRequestDraft? Draft, ReplyOutcome Outcome, string? Problem, TokenUsage Usage);
public sealed class ReturnRequestExtractor(IAnthropicClient claude, IOptions<ClaudeOptions> options)
{
private const string SystemPrompt = """
You turn a customer's free-text return request for Bookstore, an online bookstore, into the given JSON structure.
Use only what the customer wrote. If the order number is not in the text, use null; never invent one.
Urgency is high only if the customer mentions a deadline or a damaged gift.
""";
// Built once: the API compiles the schema into a grammar and caches it for 24 hours.
private static readonly JsonOutputFormat Format = new() { Schema = ClaudeJsonSchema.AsSchemaDictionary<ReturnRequestDraft>() };
public MessageCreateParams BuildRequest(string customerText) => new()
{
Model = options.Value.Model,
MaxTokens = 2048,
System = SystemPrompt,
OutputConfig = new OutputConfig { Format = Format, Effort = Effort.Low },
Messages = [new UserMessageParam { Content = customerText }],
};
public async Task<ExtractionResult> ExtractAsync(string customerText, CancellationToken cancellationToken)
{
Message message = await claude.Messages.Create(BuildRequest(customerText), cancellationToken);
ClaudeReply reply = ClaudeReplyReader.Read(message);
// Refusal and max_tokens can leave text that does not match the schema; do not parse it.
if (reply.Outcome != ReplyOutcome.Complete)
{
return new ExtractionResult(null, reply.Outcome, $"No structured output ({reply.Outcome}).", reply.Usage);
}
try
{
ReturnRequestDraft? draft = JsonSerializer.Deserialize<ReturnRequestDraft>(reply.Text, ClaudeJsonSchema.SerializerOptions);
return new ExtractionResult(draft, reply.Outcome, draft is null ? "Empty output." : null, reply.Usage);
}
catch (JsonException ex)
{
return new ExtractionResult(null, reply.Outcome, $"Output did not deserialize: {ex.Message}", reply.Usage);
}
}
}
The structured outputs page explains how the grammar is cached: the first use of a given schema has extra latency while the grammar compiles, and the compiled grammar stays cached for 24 hours from its last use. Changing the structure of the schema invalidates that cache; changing only name or description does not. The documentation adds two cost notes: with structured outputs an extra system prompt describing the format is added for the model, raising input tokens slightly; and changing output_config.format breaks the prompt cache of that conversation. That is why the schema lives in a static field.
Before parsing the answer we look at stop_reason. The "Invalid outputs" section of the documentation lists two cases where schema compliance is not guaranteed: on a refusal the refusal message can take precedence over the schema, and at max_tokens the output can be cut off. In both cases we do not parse the text. According to the documentation the generated tokens are also billed on a refusal.
There is an enum note too. According to the documentation, structured outputs do not guarantee the capitalization of enum and const values; the model can return a value that differs from the schema only in capitalization, and the response completes without an error. Because JsonStringEnumConverter reads case-insensitively, "Damaged" also becomes ReturnReason.Damaged; we pinned this with a test.
Matches the Schema but Wrong: Business Rules in Code
Back to Alan's request. The model extracted what the customer wrote correctly: order 1042, reason damaged, book Dune, quantity 1. The JSON matches the schema. The problem is not in the JSON but in reality: 1042 is not Alan's order. A schema guarantees the shape of the data, not its truth. So the draft goes through a business rule check that runs in code:
using Bookstore.Api.Catalog;
using Bookstore.Api.Ordering;
namespace Bookstore.Api.Returns;
// A draft that matches the schema can still break the business rules; these checks run in code.
public sealed class ReturnPolicy(IOrderRepository orders, IBookRepository books)
{
public IReadOnlyList<string> Check(ReturnRequestDraft draft, CurrentCustomer customer)
{
List<string> problems = [];
if (draft.OrderId is null)
{
problems.Add("The order number is missing; ask the customer for it.");
return problems;
}
Order? order = orders.Find(draft.OrderId);
if (order is null || order.CustomerId != customer.Id)
{
problems.Add($"Order {draft.OrderId} was not found for this customer.");
return problems;
}
if (order.Status != "Shipped" && order.Status != "Delivered")
{
problems.Add($"Order {order.Id} is '{order.Status}'; it can be cancelled, not returned.");
}
if (draft.Items.Count == 0)
{
problems.Add("No items were named.");
}
HashSet<string> titlesInOrder = order.Isbns
.Select(books.FindByIsbn)
.Where(b => b is not null)
.Select(b => b!.Title)
.ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (ReturnItem item in draft.Items)
{
if (!titlesInOrder.Contains(item.Title))
{
problems.Add($"'{item.Title}' is not in order {order.Id}.");
}
if (item.Quantity < 1)
{
problems.Add($"'{item.Title}' has an invalid quantity ({item.Quantity}).");
}
}
return problems;
}
}
The rules come from Bookstore's return policy: the order must belong to this customer, it must have shipped (an order being prepared is cancelled, not returned), the returned books must be in that order, and the quantity must be at least 1. As with the get_order_status tool in the fifth post, another customer's order looks "not found".
The endpoint creates nothing; it returns the draft and the list of problems:
app.MapPost("/api/returns/extract", async Task<IResult> (
ReturnText body, ClaimsPrincipal user, ReturnRequestExtractor extractor, ReturnPolicy policy, CancellationToken ct) =>
{
if (!Guid.TryParse(user.FindFirstValue(ClaimTypes.NameIdentifier), out Guid customerId))
{
return TypedResults.Unauthorized();
}
ExtractionResult result = await extractor.ExtractAsync(body.Text, ct);
if (result.Draft is null)
{
return TypedResults.Ok(new ReturnCheck(null, [result.Problem ?? "No draft."]));
}
// Nothing is created here: the draft and its problems go to the customer or a support agent to confirm.
return TypedResults.Ok(new ReturnCheck(result.Draft, policy.Check(result.Draft, new CurrentCustomer(customerId))));
});
return app;
}
}
The draft is shown to the customer or Gül's team for confirmation. Treating the model's output as a suggestion rather than a decision ties this design to the principle "LLM output is untrusted input" from the fifth post of the LLM Application Security series.
Tests
We test that the schema is closed, that the request really carries the schema, and that cut-off output is not parsed. The fake responses carry this JSON in their text block:
{"order_id":"1042","reason":"damaged","items":[{"title":"Dune","quantity":1}],"urgency":"normal","summary":"Dune arrived with a torn cover."}
using System.Text.Json;
using System.Text.Json.Nodes;
using Bookstore.Api.Ai;
using Bookstore.Api.Catalog;
using Bookstore.Api.Ordering;
using Bookstore.Api.Returns;
using Microsoft.Extensions.Options;
namespace Bookstore.Api.Tests;
public sealed class StructuredOutputTests
{
private static string Reply(string stopReason, string text) => $$$"""
{"id":"msg_01","type":"message","role":"assistant","model":"claude-haiku-5-5",
"content":[{"type":"text","text":{{{JsonSerializer.Serialize(text)}}}}],
"stop_reason":"{{{stopReason}}}","stop_sequence":null,"usage":{"input_tokens":610,"output_tokens":85}}
""";
private const string DraftJson = """
{"order_id":"1042","reason":"damaged","items":[{"title":"Dune","quantity":1}],"urgency":"normal","summary":"Dune arrived with a torn cover."}
""";
private static ReturnRequestExtractor CreateExtractor(FakeClaudeHandler handler) =>
new(handler.CreateClient(), Options.Create(new ClaudeOptions()));
private static readonly ReturnPolicy Policy = new(new InMemoryOrderRepository(), new InMemoryBookRepository());
[Fact]
public void Schema_IsClosedOnEveryObject()
{
JsonObject schema = ClaudeJsonSchema.For<ReturnRequestDraft>();
Assert.False(schema["additionalProperties"]!.GetValue<bool>());
Assert.False(schema["properties"]!["items"]!["items"]!["additionalProperties"]!.GetValue<bool>());
Assert.Equal(5, schema["required"]!.AsArray().Count);
}
[Fact]
public async Task ExtractAsync_SendsTheSchemaAndParsesTheText()
{
FakeClaudeHandler handler = new(Reply("end_turn", DraftJson));
ExtractionResult result = await CreateExtractor(handler).ExtractAsync(
"Order 1042: Dune came with a torn cover, I want to send it back.", CancellationToken.None);
Assert.Equal(new ReturnItem("Dune", 1), Assert.Single(result.Draft!.Items));
Assert.Equal(ReturnReason.Damaged, result.Draft.Reason);
JsonElement format = JsonDocument.Parse(handler.RequestBodies[0]).RootElement
.GetProperty("output_config").GetProperty("format");
Assert.Equal("json_schema", format.GetProperty("type").GetString());
Assert.False(format.GetProperty("schema").GetProperty("additionalProperties").GetBoolean());
}
[Theory]
[InlineData("refusal")]
[InlineData("max_tokens")]
public async Task ExtractAsync_DoesNotParseIncompleteOutput(string stopReason)
{
FakeClaudeHandler handler = new(Reply(stopReason, """{"order_id":"1042","reason":"dam"""));
ExtractionResult result = await CreateExtractor(handler).ExtractAsync("...", CancellationToken.None);
Assert.Null(result.Draft);
Assert.NotNull(result.Problem);
}
}
The business rule tests show that the same schema-valid draft is valid for Ada and invalid for Alan:
[Fact]
public void Deserialize_EnumCasingDifferences_StillParse()
{
// The documentation says the casing of enum values is not guaranteed.
ReturnRequestDraft? draft = JsonSerializer.Deserialize<ReturnRequestDraft>(
DraftJson.Replace("\"damaged\"", "\"Damaged\""), ClaudeJsonSchema.SerializerOptions);
Assert.Equal(ReturnReason.Damaged, draft!.Reason);
}
[Fact]
public void Policy_SchemaValidDraftForSomeoneElsesOrder_IsRejected()
{
ReturnRequestDraft draft = JsonSerializer.Deserialize<ReturnRequestDraft>(DraftJson, ClaudeJsonSchema.SerializerOptions)!;
Assert.Empty(Policy.Check(draft, new CurrentCustomer(InMemoryOrderRepository.Ada)));
Assert.Equal(["Order 1042 was not found for this customer."], Policy.Check(draft, new CurrentCustomer(InMemoryOrderRepository.Alan)));
}
[Fact]
public void Policy_BookNotInTheOrder_IsReported()
{
ReturnRequestDraft draft = new("1042", ReturnReason.WrongItem, [new ReturnItem("Emma", 1)], Urgency.Normal, "Wrong book.");
Assert.Equal(["'Emma' is not in order 1042."], Policy.Check(draft, new CurrentCustomer(InMemoryOrderRepository.Ada)));
}
Passed! - Failed: 0, Passed: 28, Skipped: 0, Total: 28, Duration: 55 ms - Bookstore.Api.Tests.dll (net10.0)
Constraints and Compatibility
These are the constraints in the documentation that matter for Bookstore.
The supported JSON Schema subset: the basic types, enum, const, anyOf, allOf (not with $ref), local $ref and definitions, required, additionalProperties: false, and specific string formats such as date-time, date, email and uuid. Not supported: recursive schemas, numeric limits such as minimum and maximum, minLength and maxLength, and values other than 0 and 1 for minItems. An unsupported feature returns a 400. The documentation says some SDK helpers remove these limits from the schema, write them into the description and validate the response against the original schema themselves; we apply these limits in code, with checks such as ReturnPolicy.
Complexity limits: in a request that uses output_config.format or strict: true, 24 optional parameters in total across all strict schemas and at most 16 parameters that use union types. In our draft every field is required, and the only union is ["string", "null"] on order_id.
Property order: in the output, required fields come first and optional fields after them. If the order matters, make every field required.
Incompatibilities: it cannot be used together with citations; if both are on, the API returns a 400. It does not work with prefill either. It works with batches, token counting and streaming. One model note as well: according to the Fable 5.2 documentation, in organizations that use customer-managed encryption keys, structured outputs (strict tool use included) are not available for Fable 5.2 and Mythos 5.2.
In Production
Version the schema. A schema is like an API contract; renaming a field affects both the grammar cache and the code that reads the draft. Pinning the schema with a test makes such a change deliberate.
Do not count schema compliance as success. Track refusals, max_tokens and business rule failures as separate metrics. There should be a confirmation step between "parsed" and "return created".
Personal data does not go into the schema. According to the documentation schemas are cached separately from prompts; for data under HIPAA the documentation says explicitly that sensitive data must not go into the schema. The same principle applies to Bookstore: do not put customer names, addresses or order contents into enum values or descriptions.
Cost. The extra system prompt raises input tokens slightly, and keeping effort low keeps the output short. For well-defined work such as extraction, confirm low effort with the evaluation tool from the third post.
Common Pitfalls
Thinking "it matched the schema, so it is correct". A schema guarantees shape; rules such as ownership, stock and dates are checked by code.
Trying to parse half a JSON. If stop_reason is not end_turn, do not trust the text.
Making a nullable field a required string in the schema. The model then has to invent the missing information. Always look at the schema you send.
Generating the schema with JsonSerializerDefaults.Web. Numbers become possible strings, and additionalProperties is missing.
Using the old output_format parameter. It is deprecated; use output_config.format.
Comparing enums case-sensitively. The documentation does not guarantee capitalization.
A Small Experiment
★☆☆ Add a DateOnly? DeliveredOn field to ReturnRequestDraft. Look at the format of this field in the schema the exporter generates and compare it with the string formats the documentation supports.
★★☆ Write a check for ClaudeJsonSchema that breaks the test if unsupported keywords (minimum, maximum, minLength, maxLength, pattern) remain in the schema. Add [Range(1, 10)] to ReturnItem.Quantity and see whether the exporter writes it into the schema.
★★★ Rewrite the ReturnClassifier from the third post with structured outputs: a record with only a reason field. Run the evaluation tool with both versions and measure whether the cases where ParseLabel returned null disappear.
With this post we have completed the core of the Messages API: request, response, model and effort, streaming, tool use and structured outputs. In the seventh post we return to cost: caching the repeated system prompt, tool definitions and conversation history with prompt caching, cache breakpoints, TTLs, and confirming with cache_read_input_tokens that the cache really works.