System.Text.Json in ASP.NET Core

Know which JSON settings ASP.NET Core applies, change them in the right place for minimal APIs and controllers, and read binding failures.

Syntaxbuilder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull); builder.Services.AddControllers() .AddJsonOptions(o => o.JsonSerializerOptions.WriteIndented = false);

ASP.NET Core reads and writes JSON with System.Text.Json. Request bodies bound to a parameter, TypedResults.Ok(...), Results.Json, HttpRequest.ReadFromJsonAsync and HttpResponse.WriteAsJsonAsync all go through it. Newtonsoft.Json is only involved if you add AddNewtonsoftJson(), and that only affects controllers.

The web defaults

The framework starts from JsonSerializerDefaults.Web, not from the plain defaults you get with JsonSerializer.Serialize(x). Only three settings differ:

SettingGeneral (plain serializer)Web (ASP.NET Core)
PropertyNamingPolicynone: CustomerIdcamelCase: customerId
PropertyNameCaseInsensitivefalsetrue: CUSTOMERID binds
NumberHandlingStrictAllowReadingFromString: "42" reads as 42

Everything else is the same: maximum depth 64 for minimal APIs (controllers set MaxDepth to 32, the MVC model-binding recursion limit), no comments or trailing commas, unknown properties skipped, a duplicate property silently overwrites the first, dictionary keys written as-is (no naming policy), nulls written, enums written as numbers, and the default encoder escapes HTML-sensitive and non-ASCII characters (< becomes \u003C, é becomes \u00E9). Decimals keep their scale, so 129.90 round-trips as 129.90. JsonSerializerOptions.Web is a cached read-only instance with these settings for your own code.

Two option objects, one name

Minimal APIs and the HttpContext helpers read Microsoft.AspNetCore.Http.Json.JsonOptions, configured with builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions...). Controllers read Microsoft.AspNetCore.Mvc.JsonOptions, configured with AddControllers().AddJsonOptions(o => o.JsonSerializerOptions...). Both types are called JsonOptions, so services.Configure<JsonOptions> with the wrong using compiles and silently configures the stack you are not using. In an app that has both, write one method that configures a JsonSerializerOptions and call it from both places, or minimal endpoints and controllers will disagree on nulls, enums and naming.

What a bad body produces

  • Wrong Content-Type: minimal APIs answer 415. application/json and any +json type such as application/vnd.shop.v2+json are accepted.
  • Malformed JSON or a value of the wrong type: minimal APIs answer 400 with an empty body. The JsonException is logged at Debug by Microsoft.AspNetCore.Http.RequestDelegateFactory, so production logs show nothing. In Development, RouteHandlerOptions.ThrowOnBadRequest is on and the exception page shows the cause. Add AddProblemDetails() and UseStatusCodePages() to give clients a problem+json body.
  • Controllers with [ApiController] return a ValidationProblemDetails whose errors are keyed by the JSON path of the failure (for example "$.total", or "$" for malformed JSON) and carry the JsonException message with line and byte position, plus an entry for the parameter name saying the field is required.

The production failure

The defaults are lenient. A property the client leaves out gets its default value: {"customerId":42} binds a Total of 0 and a non-nullable string of null, and the handler runs. A misspelt property is skipped. Teams that need a precise contract turn on the strict settings covered in Strict and Safe Deserialisation and add validation. The other decision is stability: changing the naming policy or enum format later is a breaking change for every client, so settle it before the first release and cover it with a test that compares a response to a stored JSON snapshot.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Accept {"CustomerId":"42"} from an older mobile client while new clients send {"customerId":42}, without writing a custom converter.
  • Make a v1 controller and a v2 minimal endpoint emit identical JSON for the same invoice by configuring both stacks from one method.
  • Find out why POST /orders returns an empty 400 in production by enabling Debug logs for RequestDelegateFactory.

More examples

One JSON policy for controllers and minimal APIs

Without the shared method, <code>/api/v1/invoices/1</code> would write <code>"paidAt":null</code> while <code>/api/v2/invoices/1</code> omits it, because each stack has its own options object. Putting the policy in one static method makes the two impossible to drift apart, and a snapshot test on one endpoint per stack catches anyone who configures only one of them.

Example · csharp
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.AspNetCore.Mvc;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddControllers()
    .AddJsonOptions(o => ShopJson.Configure(o.JsonSerializerOptions));
builder.Services.ConfigureHttpJsonOptions(o => ShopJson.Configure(o.SerializerOptions));

var app = builder.Build();
app.MapControllers();
app.MapGet("/api/v2/invoices/{id:int}", (int id) =>
    TypedResults.Ok(new InvoiceDto(id, "INV-2026-0042", 249.00m, PaidAt: null)));
app.Run();

public static class ShopJson
{
    // The single source of truth for the API's JSON contract.
    public static void Configure(JsonSerializerOptions options)
    {
        options.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
        options.Converters.Add(new JsonStringEnumConverter());
    }
}

public record InvoiceDto(int Id, string Number, decimal Total, DateTimeOffset? PaidAt);

[ApiController]
[Route("api/v1/invoices")]
public class InvoicesController : ControllerBase
{
    [HttpGet("{id:int}")]
    public InvoiceDto Get(int id) => new(id, "INV-2026-0042", 249.00m, PaidAt: null);
}

A problem+json body for unreadable requests

<code>UseStatusCodePages</code> fills any error response that has no body, and because <code>IProblemDetailsService</code> is registered it writes <code>application/problem+json</code> with <code>type</code>, <code>title</code>, <code>status</code> and <code>traceId</code>. The client still does not learn which property was wrong; the strictness lesson shows a handler that adds the JSON path of the failure. Log level <code>Debug</code> for <code>Microsoft.AspNetCore.Http.RequestDelegateFactory</code> reveals the cause on the server.

Example · csharp
var builder = WebApplication.CreateBuilder(args);

// Without these two lines a minimal API answers malformed JSON with an empty 400.
builder.Services.AddProblemDetails();

var app = builder.Build();
app.UseExceptionHandler();
app.UseStatusCodePages();

app.MapPost("/payments", (CapturePayment payment) =>
    TypedResults.Accepted($"/payments/{payment.PaymentId}", payment));

app.Run();

public record CapturePayment(Guid PaymentId, int OrderId, decimal Amount, string Currency);

Reusing one JsonSerializerOptions instance

Every <code>JsonSerializerOptions</code> instance keeps its own cache of reflected contracts, so <code>new JsonSerializerOptions()</code> inside a hot method rebuilds the metadata on every call and shows up as CPU and allocation spikes; analyzer CA1869 flags it. <code>GetFromJsonAsync</code> without options already uses the web defaults; pass a static instance only when you need different settings. The currency codes are escaped with <code>Uri.EscapeDataString</code> so a value such as <code>USD/../admin</code> cannot change the upstream path.

Example · csharp
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;

public sealed class FxRatesClient(HttpClient http)
{
    // Options cache per-type metadata. A new instance per call rebuilds that cache every time.
    private static readonly JsonSerializerOptions Json = new(JsonSerializerDefaults.Web)
    {
        NumberHandling = JsonNumberHandling.Strict,
    };

    public async Task<decimal> GetRateAsync(string from, string to, CancellationToken ct)
    {
        var quote = await http.GetFromJsonAsync<FxQuote>($"/v1/rates/{Uri.EscapeDataString(from)}/{Uri.EscapeDataString(to)}", Json, ct)
            ?? throw new InvalidOperationException($"No rate for {from}/{to}");
        return quote.Rate;
    }
}

public record FxQuote(string From, string To, decimal Rate, DateTimeOffset AsOf);

Discussion

  • Be the first to comment on this lesson.