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);| Concern | Setting | Effect |
|---|---|---|
| Field naming | PropertyNamingPolicy | camelCase, snake_case or a custom policy |
| Nulls | DefaultIgnoreCondition | Omit nulls, or write them explicitly |
| Unknown members | Default behaviour | Ignored on read unless a strict resolver is set |
| Missing members | Default behaviour | Leave the property at its default value |
| Numbers as strings | NumberHandling | Accept a number sent as a string |
| Cycles | ReferenceHandler | Prevent 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.
JsonDocumentis read-only and cheap for inspection;JsonNodeis mutable but allocates more. UseUtf8JsonReaderfor the fastest forward-only pass.- Round-tripping through your own DTOs is safer than deserialising into
objectand 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.
Related
Performance: allocation, Span and benchmarking Asynchronous programming with async and await
Last refreshed 2026-09-18.