Mert Özen Further With Every Line
Backend I/O in .NET — #04

System.Text.Json in Depth 1: Options, Converters, Polymorphism and Strict Mode

Mert Özen Nov 14, 2026 13 min 2 views
System.Text.Json in Depth 1: Options, Converters, Polymorphism and Strict Mode

By default System.Text.Json takes the last value of a duplicate property and silently skips missing and unknown fields; for external payloads you close that with .NET 10's JsonSerializerOptions.Strict and AllowDuplicateProperties = false. We show the web defaults, naming policies, required and nullable rules, polymorphism with Bookstore's PaymentMethod hierarchy, a Money converter and contract customization, all with real output.

Version note: The code in this post was built and run with .NET 10 (System.Text.Json 10).

Guess First

The payment service of Bookstore, the online bookstore used as the shared example across this site's series, receives this request:

{"OrderNumber":"BS-0000042","Amount":"10.00 TRY","Amount":"9999.00 TRY","Method":{"type":"card","Brand":"Visa","Last4":"4242"}}

Amount appears twice. Does JsonSerializer.Deserialize<PaymentRequest> throw, take the first value, or the last? And if another service in front checks the amount and reads the first value, what happens?

Second question: the Angular client sends {"orderNumber":"BS-0000042","customerName":"Ada"}, and on the C# side the properties are OrderNumber and CustomerName. If you call JsonSerializer.Deserialize<Order>(json) in a console app, what is OrderNumber?

Third: in the JSON of a payment method, the "type":"card" field comes last. Does polymorphic deserialization find it?

This post covers the settings and model rules of System.Text.Json, STJ for short, then .NET 10's Strict settings and polymorphism. The examples are in the Bookstore.IO.Json console app in the series' repo:

dotnet run --project src/Bookstore.IO.Json -- duplicates

Defaults and JsonSerializerOptions.Web

STJ's defaults, used when you pass no options at all, differ from the web defaults ASP.NET Core uses. Let's serialize the same order both ways:

Console.WriteLine($"Default: {JsonSerializer.Serialize(order)}");
Console.WriteLine($"Web:     {JsonSerializer.Serialize(order, JsonSerializerOptions.Web)}");
Default: {"OrderNumber":"BS-0000042","CustomerName":"Ada","CreatedAtUtc":"2026-11-14T09:30:00Z","Status":1,"Currency":"TRY","Items":[{"Isbn":"9780441172719","Title":"Dune","Quantity":2,"UnitPrice":349.90,"LineTotal":699.80}],"Total":699.80}
Web:     {"orderNumber":"BS-0000042","customerName":"Ada","createdAtUtc":"2026-11-14T09:30:00Z","status":1,"currency":"TRY","items":[{"isbn":"9780441172719","title":"Dune","quantity":2,"unitPrice":349.90,"lineTotal":699.80}],"total":699.80}
Web options: case-insensitive=True, numbers=AllowReadingFromString, naming=JsonCamelCaseNamingPolicy

JsonSerializerOptions.Web arrived in .NET 9 and is a read-only copy of the settings ASP.NET Core uses: property names are written in camelCase, reading ignores case, and numbers in quotes are accepted. The defaults write the property name as is and are case-sensitive when reading.

The answer to the second question is here too. Reading camelCase JSON with the defaults matches no property at all:

Default: OrderNumber=null, Items=null
Web:     OrderNumber=BS-0000042, Items=0

No exception; an empty object came back silently. Note that Items is declared as a non-nullable IReadOnlyList<OrderItem> but came back null; the defaults ignore nullable annotations. This bug shows up most often when JSON returned by ASP.NET Core is read with new JsonSerializerOptions() in a console app, a test project or a Worker. In any code that talks to a Web API, use JsonSerializerOptions.Web or an instance derived from it.

Also note Total and LineTotal in the output: every public property with a getter is serialized, computed values included. When reading, these fields are skipped silently, because there is nowhere to set them.

Naming Policies, Enums and Numbers

If a supplier or payment provider expects snake_case, you can use the built-in policies added in .NET 8: SnakeCaseLower, SnakeCaseUpper, KebabCaseLower, KebabCaseUpper. Enums are written as numbers by default; JsonStringEnumConverter turns them into names and can apply the same policy to enum values:

var snake = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
    Converters = { new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower) }
};
Snake:   {"order_number":"BS-0000042","customer_name":"Ada","created_at_utc":"2026-11-14T09:30:00Z","status":"paid","currency":"TRY","items":[{"isbn":"9780441172719","title":"Dune","quantity":2,"unit_price":349.90,"line_total":699.80}],"total":699.80}

Carrying an enum value as a number is fragile: if a new value is added in the middle of the enum, the numbers of the following values shift and old clients read the wrong status. In API contracts it is safer to carry enums as strings. If you need to set the name of a single value specifically, there is the [JsonStringEnumMemberName] attribute from .NET 9; in the polymorphism example below you will see RefundPending written as "refund-pending".

There are two details with numbers. The defaults reject a number in quotes; the Web settings accept it through JsonNumberHandling.AllowReadingFromString:

Default: JsonException: The JSON value could not be converted to Bookstore.Ordering.OrderItem. Path: $.quantity | LineNumber: 0 | BytePositionInLine: 53.
Web:     Dune x2 @ 349.90

The second is decimal. STJ writes decimal as a JSON number and keeps the trailing zero in 349.90. If the other side is JavaScript, it reads the number as a double, so for large amounts and exact cent arithmetic it is safer to carry money as a string. We will do exactly that for Money shortly.

Model Rules: required, Constructors and Nullable

The most dangerous default of deserialization is leaving missing fields silently at their default value. STJ has several ways to tighten this, and each closes a different gap:

public sealed class PaymentRequest
{
    public required string OrderNumber { get; init; }
    public required Money Amount { get; init; }
    public required PaymentMethod Method { get; init; }
    public PaymentStatus Status { get; init; }
    public DateOnly? DueDate { get; init; }
    public string? Note { get; init; }
}
required: JsonException: JSON deserialization for type 'Bookstore.IO.Json.Payments.PaymentRequest' was missing required properties including: 'Amount'.
unmapped: JsonException: The JSON property 'Discount' could not be mapped to any .NET member contained in type 'Bookstore.Ordering.OrderItem'.
default: Title is null
nullable: JsonException: The constructor parameter 'Title' on type 'Bookstore.Ordering.OrderItem' doesn't allow null values. Consider updating its nullability annotation. Path: $.Title | LineNumber: 0 | BytePositionInLine: 36.
default: missing Title becomes null
ctor: JsonException: JSON deserialization for type 'Bookstore.Ordering.OrderItem' was missing required properties including: 'Title'.

In order: C#'s required modifier is enforced by STJ too; if the property is missing from the JSON, you get an exception. UnmappedMemberHandling = JsonUnmappedMemberHandling.Disallow (.NET 8) fails when a field arrives that has no counterpart in the model. The client sent Discount, but the model has no such field; the default behavior is to ignore it, which means silently processing a request the client believes carries a discount without one.

The last two lines matter for records. OrderItem is a record with a primary constructor, and Title is a non-nullable string. With the defaults, both "Title":null and a Title that was never sent come back as null. RespectNullableAnnotations, added in .NET 9, catches the first case, and RespectRequiredConstructorParameters catches the second. Both are off by default because turning them on can break existing applications.

There is also a setting that goes the other way: JsonObjectCreationHandling.Populate (.NET 8). By default, for a collection property that appears in the JSON, STJ creates a new instance and replaces the old one. With Populate, it adds to the existing instance:

public sealed class BookFilter
{
    public List<string> Tags { get; set; } = ["in-stock"];
}
Replace:  [new]
Populate: [in-stock, new]

This is useful for configuration objects with default values or for read-only collection properties. Which one you want depends on the context, though; a client that sends Tags in an API request most likely means to send the whole list.

.NET 10: Strict and Duplicate Properties

The answer to the first question:

var payment = JsonSerializer.Deserialize<PaymentRequest>(json)!;
Console.WriteLine($"Default: Amount = {payment.Amount}");

using (var doc = JsonDocument.Parse(json))
{
    JsonElement first = doc.RootElement.EnumerateObject().First(p => p.Name == "Amount").Value;
    Console.WriteLine($"JsonDocument, first Amount property: {first}");
}
Default: Amount = 9999.00 TRY
JsonDocument, first Amount property: 10.00 TRY
AllowDuplicateProperties=false: JsonException: Duplicate property 'Amount' encountered during deserialization of type 'Bookstore.IO.Json.Payments.PaymentRequest'.
Strict: JsonException: Duplicate property 'Amount' encountered during deserialization of type 'Bookstore.IO.Json.Payments.PaymentRequest'.

With the defaults, the last value wins: the amount is 9,999 TRY. The JSON specification does not define how duplicate properties are handled, and parsers behave differently; some take the first value, some the last, some fail. That is exactly where the security risk lies: a gateway or fraud check in front that verifies amounts and limits reads the first value (10 TRY) and approves the request, while the payment service behind it processes the last one (9,999 TRY). Two components read the same request differently, and the check is bypassed. We saw it even inside a single process in the example: code that took the first match on a JsonDocument found 10 TRY.

.NET 10 brings two things for this. AllowDuplicateProperties = false throws when the same property arrives a second time; JsonDocumentOptions has the same setting. JsonSerializerOptions.Strict gathers several safe settings in one ready-made instance:

Strict: unmapped=Disallow, duplicates=False, case-insensitive=False, nullable=True, ctor=True
Strict reads default output: Dune
camelCase: JsonException: The JSON property 'isbn' could not be mapped to any .NET member contained in type 'Bookstore.Ordering.OrderItem'.
Strict + camelCase: Dune x2

So Strict means: no unknown fields, no duplicates, case-sensitive matching, and respecting nullable annotations and required constructor parameters. According to Microsoft's notes, Strict can read JSON serialized with the default settings. It cannot read JSON produced with the web settings (camelCase), though; that is the error in the example. If you want it in a Web API, derive from Strict and add the naming policy:

var strictWeb = new JsonSerializerOptions(JsonSerializerOptions.Strict)
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

For every external payload that deals with money, permissions or stock, these strict settings are a sensible starting point. In internal traffic between your own services, where new fields can arrive because of version differences, UnmappedMemberHandling.Disallow can cause trouble during a rolling deploy; there it may be more practical to keep the duplicate check on and allow unknown fields.

Polymorphism: The PaymentMethod Hierarchy

In Bookstore you can pay by card or by bank transfer. We describe the hierarchy to STJ with attributes:

[JsonPolymorphic(TypeDiscriminatorPropertyName = "type")]
[JsonDerivedType(typeof(CardPayment), "card")]
[JsonDerivedType(typeof(BankTransferPayment), "bank-transfer")]
public abstract record PaymentMethod;

public sealed record CardPayment(string Brand, string Last4) : PaymentMethod;

public sealed record BankTransferPayment(string Iban, string Reference) : PaymentMethod;
{"orderNumber":"BS-0000042","amount":"699.80 TRY","method":{"type":"bank-transfer","iban":"TR000000000000000000000000","reference":"BS-0000042"},"status":"refund-pending","dueDate":"2026-11-21","note":null}
Round trip: BankTransferPayment, 699.80 TRY, RefundPending
[{"type":"card","brand":"Visa","last4":"4242"},{"type":"bank-transfer","iban":"TR000000000000000000000000","reference":"BS-0000043"}]

As the discriminator we use a fixed, short string ("card", "bank-transfer"), not the C# type name. That way renaming the class or its namespace does not break the contract, and the client can only choose one of the allowed types. Now to the third question and the failure cases:

discriminator last: NotSupportedException: The JSON payload for polymorphic interface or abstract type 'Bookstore.IO.Json.Payments.PaymentMethod' must specify a type discriminator. Path: $ | LineNumber: 0 | BytePositionInLine: 9.
AllowOutOfOrderMetadataProperties: CardPayment { Brand = Visa, Last4 = 4242 }
unknown type: JsonException: Read unrecognized type discriminator id 'crypto'. Path: $ | LineNumber: 0 | BytePositionInLine: 26.
no type: NotSupportedException: The JSON payload for polymorphic interface or abstract type 'Bookstore.IO.Json.Payments.PaymentMethod' must specify a type discriminator. Path: $ | LineNumber: 0 | BytePositionInLine: 9.

By default STJ expects the discriminator as the first property of the object, because it reads the object in a single pass and wants to know up front which type to create. When "type" comes last, the error message is the same as if it had never been sent. JSON produced by STJ itself always puts the discriminator first, so this causes no trouble; but if another language or a hand-written client produces the JSON, the field order is not guaranteed. AllowOutOfOrderMetadataProperties = true, added in .NET 9, fixes this; the price is that STJ buffers the object until it finds the type.

Two more details. An unknown discriminator produces a JsonException, while a missing discriminator on an abstract base type produces a NotSupportedException; error handling code needs to cover both. Had the base type not been abstract, a payload without a discriminator would have been read silently as the base type. That is why keeping base types abstract in hierarchies is safer.

A Custom Converter: Money and DateOnly

In Bookstore, money is an amount plus a currency, and the contract carries it as a single string: "249.90 TRY". We do that with a JsonConverter<T>:

[JsonConverter(typeof(MoneyJsonConverter))]
public readonly record struct Money(decimal Amount, string Currency)
{
    public override string ToString() => $"{Amount.ToString("0.00", CultureInfo.InvariantCulture)} {Currency}";
}

public sealed class MoneyJsonConverter : JsonConverter<Money>
{
    public override Money Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        if (reader.TokenType != JsonTokenType.String)
        {
            throw new JsonException($"Money must be a string like \"249.90 TRY\", got {reader.TokenType}.");
        }

        string text = reader.GetString()!;
        int space = text.IndexOf(' ');
        if (space <= 0
            || !decimal.TryParse(text.AsSpan(0, space), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out decimal amount)
            || text.Length - space - 1 != 3)
        {
            throw new JsonException($"Invalid money value \"{text}\".");
        }
        return new Money(amount, text[(space + 1)..]);
    }

    public override void Write(Utf8JsonWriter writer, Money value, JsonSerializerOptions options) =>
        writer.WriteStringValue(value.ToString());
}
money: JsonException: Money must be a string like "249.90 TRY", got Number.
money: JsonException: Invalid money value "249,90 TL".
Money: 249.90 TRY

Three rules matter in a converter. Formatting and parsing always use CultureInfo.InvariantCulture; otherwise money drifts between a server on Turkish Windows writing 249,90 and a client expecting 249.90. Invalid input throws JsonException; STJ adds the path and line information to it, and ASP.NET Core turns it into a 400 Bad Request. Binding the converter to the type with an attribute makes it apply wherever the type is used (a property, a collection element, a polymorphic subtype).

DateOnly has been supported directly since .NET 7 in ISO 8601 format ("2026-11-14"). If a partner sends dates as "14.11.2026", handling that format with a converter added only to that integration's options is better than changing the format of the whole API:

public sealed class DayMonthYearDateOnlyConverter : JsonConverter<DateOnly>
{
    private const string Format = "dd.MM.yyyy";

    public override DateOnly Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
        DateOnly.ParseExact(reader.GetString()!, Format, CultureInfo.InvariantCulture);

    public override void Write(Utf8JsonWriter writer, DateOnly value, JsonSerializerOptions options) =>
        writer.WriteStringValue(value.ToString(Format, CultureInfo.InvariantCulture));
}
DateOnly default: "2026-11-14"
DateOnly partner: "14.11.2026"
Parsed: 2026-11-21

Contract Customization: TypeInfoResolver Modifiers

Sometimes you want to change a type's JSON contract but cannot touch the type or do not want to add attributes. Contract customization, added in .NET 7, lets you edit the metadata STJ builds for a type (JsonTypeInfo) before serialization starts. Example: the same Customer object is written with the phone number in the API and without it in the logs.

public sealed record Customer(string Name, string Email)
{
    [SensitiveData]
    public string? Phone { get; init; }
}

var forLogs = new JsonSerializerOptions
{
    TypeInfoResolver = new DefaultJsonTypeInfoResolver
    {
        Modifiers = { HideSensitiveData }
    }
};

// Contract customization: drop every property marked [SensitiveData].
private static void HideSensitiveData(JsonTypeInfo typeInfo)
{
    if (typeInfo.Kind != JsonTypeInfoKind.Object) return;
    foreach (JsonPropertyInfo property in typeInfo.Properties)
    {
        if (property.AttributeProvider?.IsDefined(typeof(SensitiveDataAttribute), inherit: true) == true)
        {
            property.ShouldSerialize = static (_, _) => false;
        }
    }
}
API:  {"Name":"Ada","Email":"ada@example.com","Phone":"+90 555 000 00 00"}
Logs: {"Name":"Ada","Email":"ada@example.com"}

A modifier runs once per type and the result is cached in the options instance; there is no reflection cost on every serialization. The same technique lets you rename a property, make it read-only or add polymorphism information to a type without attributes. The + in the output is a separate matter: STJ's default encoder escapes characters such as +, < and > so that the JSON stays safe even when embedded in an HTML page. It is valid JSON and every parser reads it correctly; it can only surprise you when reading by eye.

In Production

Cache the JsonSerializerOptions instance. Every options instance builds and stores metadata for the types it sees; writing new JsonSerializerOptions { ... } on every call makes it redo that work each time. That is why the CA1869 analyzer, added in .NET 8, flags this usage. Keep the options in a static field or as a single instance through DI. After first use the instance is locked; trying to change it throws an exception.

In ASP.NET Core, configure settings in one place: ConfigureHttpJsonOptions for minimal APIs, AddJsonOptions for controllers. If the app's own HttpClient calls, background jobs and tests do not use the same settings, you get bugs where the Worker cannot read what the API wrote; the camelCase example above is exactly that.

Do not return error messages to the client as they are. STJ's messages contain .NET type names (Bookstore.IO.Json.Payments.PaymentRequest); log them, and return a plain validation error with the path to the client.

Version the contract. Because strict settings reject a new field, if clients and services are not deployed at the same time, define new fields on the reader side first, then start sending them from the writer side.

Common Pitfalls

Newtonsoft.Json habits. Newtonsoft matches case-insensitively by default and accepts numbers in quotes; STJ's defaults do neither. This is the most common reason tests pass while fields come back empty in production after moving a project to STJ. Use the web settings or configure the difference deliberately.

Carrying the type name in JSON. Newtonsoft's TypeNameHandling setting can create any .NET type based on the $type field in the payload, which has made it a source of remote code execution vulnerabilities for years. STJ's polymorphism only creates the types you allow with [JsonDerivedType]; keep that design and use fixed discriminators instead of type names. The details of insecure deserialization are in the Deserialization and Mass Assignment post of the OWASP Top 10 series.

Assuming duplicate properties are harmless. In a single service they may look that way; the moment a gateway, a WAF or a service written in another language sits in between, two components can read the same request differently. Use AllowDuplicateProperties = false for external payloads.

Leaving the polymorphic base type concrete. If no discriminator arrives, STJ silently creates the base type and the subtype's fields are lost. Make the base type abstract.

Leaving money and date formats to the culture. Always use CultureInfo.InvariantCulture in converters and ToString calls.

A Small Experiment

★☆☆ Add a Failed value to the PaymentStatus enum between Authorized and Captured. With settings that serialize the enum as a number, read an old JSON and see how the result shifts; then repeat with JsonStringEnumConverter.

★★☆ Add GiftCardPayment(string Code) to the PaymentMethod hierarchy. See what an old client that does not know the new type (a copy whose attribute list has no "gift-card") gets when it reads this JSON, and research ways to fall back to an UnknownPayment for unknown types.

★★★ Write a TypeInfoResolver modifier that masks the values of properties marked [SensitiveData] ("+90 *** *** ** 00") instead of dropping them. Hint: you can wrap the JsonPropertyInfo.Get delegate.

Everything in this post ran on reflection-based metadata. The next post moves to source generation: we will measure startup time and allocation with JsonSerializerContext, choose between JsonDocument, JsonNode and Utf8JsonReader, and read a 1-million-order JSON export without loading it into memory.