JSON serialisation with System.Text.Json

The built-in serialiser is fast and allocation-light, and source generation removes its startup cost and its reflection dependency.

Options that change behaviour

using System.Text.Json;
using System.Text.Json.Serialization;

var options = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    DictionaryKeyPolicy = JsonNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    NumberHandling = JsonNumberHandling.AllowReadingFromString,
    WriteIndented = false,
    // Do not include the converter that lets a payload name the type it wants
    // to deserialise into, unless you add your own type resolver.
    ReferenceHandler = ReferenceHandler.IgnoreCycles,
};

public sealed class Order
{
    [JsonPropertyName("id")] public required string Id { get; init; }
    [JsonPropertyName("placedAt")] public DateTimeOffset PlacedAt { get; init; }
    [JsonPropertyName("total")] public Money Total { get; init; } = new();

    // Never serialised out, never accepted in
    [JsonIgnore] public string InternalNote { get; set; } = "";
}

public readonly record struct Money(decimal Amount, string Currency);
ConcernSettingEffect
Field namingPropertyNamingPolicycamelCase, snake_case or a custom policy
NullsDefaultIgnoreConditionOmit nulls, or write them explicitly
Unknown membersDefault behaviourIgnored on read unless a strict resolver is set
Missing membersDefault behaviourLeave the property at its default value
Numbers as stringsNumberHandlingAccept a number sent as a string
CyclesReferenceHandlerPrevent a stack overflow on a circular graph
Polymorphism[JsonDerivedType]Write and read a discriminator correctly
// Polymorphism with an explicit discriminator, the safe form
[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind",
                 UnknownDerivedTypeHandling = JsonUnknownDerivedTypeHandling.FailSerialization)]
[JsonDerivedType(typeof(CardPayment), "card")]
[JsonDerivedType(typeof(BankTransfer), "transfer")]
public abstract class Payment
{
    public required string Id { get; init; }
}

public sealed class CardPayment : Payment
{
    public required string Last4 { get; init; }
}

Source generation and streaming

[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(IReadOnlyList<Order>))]
[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    GenerationMode = JsonSourceGenerationMode.Metadata)]
public partial class AppJsonContext : JsonSerializerContext { }

// Serialise with the generated context: no reflection, no first-call warm-up cost
string json = JsonSerializer.Serialize(order, AppJsonContext.Default.Order);
Order? parsed = JsonSerializer.Deserialize(json, AppJsonContext.Default.Order);

// In ASP.NET Core, wire it into the HTTP pipeline once
builder.Services.ConfigureHttpJsonOptions(o =>
{
    o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default);
});
// Streaming a large payload: deserialise one element at a time
public static async IAsyncEnumerable<Order> ReadOrdersAsync(
    Stream stream, [EnumeratorCancellation] CancellationToken ct = default)
{
    await foreach (var order in JsonSerializer
        .DeserializeAsyncEnumerable<Order>(stream, ct))
    {
        if (order is not null) yield return order;
    }
}

// And writing incrementally, without building the whole string in memory
public static async Task WriteAsync(IAsyncEnumerable<Order> orders, Stream output,
                                    CancellationToken ct)
{
    await using var writer = new Utf8JsonWriter(output);
    writer.WriteStartArray();
    await foreach (var order in orders.WithCancellation(ct))
    {
        JsonSerializer.Serialize(writer, order, AppJsonContext.Default.Order);
    }
    writer.WriteEndArray();
    await writer.FlushAsync(ct);
}
  • Source generation is what makes trimming and Native AOT viable, because the serialiser no longer needs reflection metadata at runtime.
  • Set the same options in the context as in the runtime options object; a mismatch is the source of many confusing round-trip bugs.
  • Streaming avoids holding the whole document in memory, but you cannot query it twice and you must handle a failure halfway through.
  • JsonDocument is read-only and cheap for inspection; JsonNode is mutable but allocates more. Use Utf8JsonReader for the fastest forward-only pass.
  • Round-tripping through your own DTOs is safer than deserialising into object and re-serialising, which invites an injection of unexpected members.
⚠️
A custom TypeInfoResolver that allows arbitrary types to be constructed from the payload is a deserialisation gadget. Do not accept a type name from the input, and do not resolve types dynamically from strings.

FAQ

Why is my property missing from the JSON?
Check the naming policy, the ignore condition and whether the property has a public getter. System.Text.Json ignores fields by default unless you opt in or use source generation with fields enabled.
Should I use JsonSerializerOptions.Default or a cached instance?
Always cache and reuse one instance. Creating options per call rebuilds the metadata cache, which is the single biggest performance mistake with this API.

Performance: allocation, Span and benchmarking Asynchronous programming with async and await

Last refreshed 2026-09-18.