System.Text.Json is the built-in JSON serializer for .NET, and it is the default choice for everything from ASP.NET Core APIs to configuration files and message payloads. This guide is for C# developers who already call JsonSerializer.Serialize and Deserialize and want to go further: reflection-free source generation with JsonSerializerContext, writing custom converters, polymorphic serialization with JsonDerivedType, the JsonNode/JsonDocument/JsonElement document model, low-level control with Utf8JsonReader and Utf8JsonWriter, streaming, security hardening, what changed in .NET 9 and .NET 10, and a practical path for migrating off Newtonsoft.Json.

What Is System.Text.Json?#

System.Text.Json shipped with .NET Core 3.0 as a high-performance, low-allocation JSON stack built directly on Span<T> and UTF-8 bytes, rather than on TextReader/TextWriter over UTF-16 strings the way Newtonsoft.Json is. It became the default serializer in ASP.NET Core from .NET Core 3.0 onward, and the runtime team has kept extending it release over release rather than treating it as a finished library: .NET 7 added polymorphic serialization, .NET 8 added source-generator improvements and the [JsonRequired]/required-member support that mirrors C# required properties, and .NET 9 and .NET 10 added the schema export, strictness and streaming features covered later in this guide. It is not a strict feature-for-feature clone of Newtonsoft.Json, and a handful of gaps remain, but for new code targeting modern .NET it is the right default.

How System.Text.Json Works: The Serialization Pipeline#

Every high-level JsonSerializer.Serialize/Deserialize call ultimately walks down to a Utf8JsonWriter or Utf8JsonReader operating on UTF-8 bytes, which is why converting from and to UTF-16 string at the boundary, rather than internally, is one of the main reasons the library is fast. Between the high-level API and that low-level layer sits contract metadata: for each type, System.Text.Json needs to know which properties to (de)serialize, their names, converters and ordering. That metadata comes from one of two sources: reflection, resolved and cached lazily the first time a type is seen, or source generation, where a Roslyn generator emits the metadata at compile time so no reflection happens at run time at all. Both paths produce the same runtime model, JsonTypeInfo<T>, so custom converters and options behave the same way regardless of which mode produced the contract.

Getting Started: JsonSerializerOptions#

JsonSerializerOptions controls naming, formatting and behavior, and it should always be created once and reused, because building the resolved contract for a type is comparatively expensive and JsonSerializerOptions caches it internally per instance.

C#
private static readonly JsonSerializerOptions s_options = new()
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    WriteIndented = false,
    NumberHandling = JsonNumberHandling.AllowReadingFromString
};

string json = JsonSerializer.Serialize(order, s_options);
Order? restored = JsonSerializer.Deserialize<Order>(json, s_options);

For ASP.NET Core-style camelCase behavior outside of a web project, use the JsonSerializerOptions.Web singleton added in .NET 9 instead of constructing an equivalent options instance by hand; it matches the same defaults minimal APIs and MVC use for JSON. Never construct a new JsonSerializerOptions per call in a hot path: each instance builds and caches its own contract, so reusing one static instance is both faster and uses less memory.

Source Generation with JsonSerializerContext#

Source generation replaces the reflection-based contract with one Roslyn emits at compile time, into a partial class that derives from JsonSerializerContext. It removes the startup cost of reflecting over your types, produces metadata that a trimmed or Native AOT application can use safely, since reflection over unreferenced members does not survive trimming, and lets the analyzer catch unsupported types at build time instead of at run time. See Roslyn Source Generators and Analyzers in .NET for how the generator mechanism itself works.

C#
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(List<Order>))]
[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    GenerationMode = JsonSourceGenerationMode.Default)]
internal partial class AppJsonContext : JsonSerializerContext
{
}

string json = JsonSerializer.Serialize(order, AppJsonContext.Default.Order);
Order? restored = JsonSerializer.Deserialize(json, AppJsonContext.Default.Order);

GenerationMode.Serialization trims the generated code down to serialization only when you never deserialize a given type, which shrinks binary size further in AOT-published apps. Minimal APIs and MVC controllers can be pointed at a generated context with options.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default), or combined with the reflection resolver as a fallback for types the context does not cover.

Custom Converters with JsonConverter<T>#

A custom JsonConverter<T> takes over reading and writing for a single type, which is the right tool when the wire format does not match the CLR shape, such as serializing a value object as a primitive instead of an object.

C#
public sealed class MoneyConverter : JsonConverter<Money>
{
    public override Money Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
    {
        decimal amount = reader.GetDecimal();
        return new Money(amount);
    }

    public override void Write(Utf8JsonWriter writer, Money value, JsonSerializerOptions options)
    {
        writer.WriteNumberValue(value.Amount);
    }
}

// Register once, in options or with [JsonConverter(typeof(MoneyConverter))] on the type
JsonSerializerOptions options = new() { Converters = { new MoneyConverter() } };

Prefer a converter over [JsonIgnore] plus a shadow property when the transformation is genuinely about representation; reach for [JsonPropertyName], [JsonIgnore] and [JsonInclude] first for simpler renaming and visibility needs, since they need no custom code at all.

Polymorphic Serialization with JsonDerivedType#

Serializing a base type and getting the correct derived type back on deserialization needs an explicit contract, because JSON has no type information of its own. [JsonPolymorphic] and [JsonDerivedType], added in .NET 7, declare a type discriminator that System.Text.Json writes on serialization and reads back on deserialization to pick the right derived type.

C#
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(CardPayment), typeDiscriminator: "card")]
[JsonDerivedType(typeof(BankTransferPayment), typeDiscriminator: "bank_transfer")]
public abstract record PaymentMethod;

public sealed record CardPayment(string Last4Digits) : PaymentMethod;
public sealed record BankTransferPayment(string Iban) : PaymentMethod;

// {"$type":"card","last4Digits":"4242"} round-trips to a CardPayment instance
List<PaymentMethod> methods = JsonSerializer.Deserialize<List<PaymentMethod>>(json)!;

This works with both reflection and source generation, though the source-generated context needs [JsonSerializable] attributes for every derived type as well as the base type. Without a discriminator, deserializing into a base or interface type either fails or silently produces the base type's own members only, so add one deliberately rather than discovering the gap in production.

The JSON DOM: JsonNode, JsonDocument and JsonElement#

Three different types read JSON without a target CLR model, and choosing the right one matters for both allocations and ergonomics. JsonDocument parses into an immutable, pooled buffer and exposes a JsonElement tree; it is the cheapest option when you only need to read values, but it must be disposed, and values do not outlive it unless cloned. JsonElement is a lightweight, read-only struct view for navigating and pattern-matching. JsonNode (with JsonObject and JsonArray) is mutable and heavier, and is the right choice when you need to build or edit a JSON tree programmatically, such as patching a configuration document.

C#
using JsonDocument document = JsonDocument.Parse(json);
if (document.RootElement.TryGetProperty("total", out JsonElement totalElement))
{
    decimal total = totalElement.GetDecimal();
}

JsonNode node = JsonNode.Parse(json)!;
node["status"] = "shipped";
node["shippedAtUtc"] = DateTimeOffset.UtcNow;
string updated = node.ToJsonString();

Use JsonDocument/JsonElement for read-only inspection of payloads you do not control the shape of, and JsonNode when you are constructing or transforming JSON rather than mapping it onto a POCO.

Low-Level Control with Utf8JsonReader and Utf8JsonWriter#

Utf8JsonReader is a forward-only, allocation-free ref struct that reads tokens directly from a UTF-8 byte span, and Utf8JsonWriter writes tokens directly to a buffer or stream; both sit underneath every higher-level API. Reach for them directly when you need to parse or emit JSON without ever materializing an intermediate object graph, which matters in hot paths that only care about one or two fields of a much larger payload. The ref struct nature of Utf8JsonReader means it follows the same stack-only rules as Span<T>, explained in Span<T>, Memory<T> and Ref Structs Explained.

C#
static string? ExtractOrderId(ReadOnlySpan<byte> utf8Json)
{
    var reader = new Utf8JsonReader(utf8Json);
    while (reader.Read())
    {
        if (reader.TokenType == JsonTokenType.PropertyName &&
            reader.ValueTextEquals("orderId") && reader.Read())
        {
            return reader.GetString();
        }
    }
    return null;
}

Since .NET 9, Utf8JsonReader can also read multiple whitespace-separated top-level JSON values from one buffer or stream when JsonReaderOptions.AllowMultipleValues is set, which is useful for newline-delimited JSON (NDJSON) logs without wrapping them in an array first.

Streaming Large Payloads#

For large payloads, materializing the whole document in memory before processing it defeats the purpose of a low-allocation serializer. JsonSerializer.DeserializeAsyncEnumerable<T> streams a top-level JSON array element by element, and SerializeAsync writes directly to a stream instead of building a string first.

C#
await using FileStream stream = File.OpenRead("orders.json");

await foreach (Order order in JsonSerializer.DeserializeAsyncEnumerable<Order>(stream, s_options))
{
    await ProcessAsync(order);
}

For ASP.NET Core responses, HttpContext.Response.WriteAsJsonAsync and the IAsyncEnumerable<T> overloads of minimal API results already stream results using these APIs, so a handler that returns IAsyncEnumerable<T> avoids buffering an entire response in memory; see Minimal APIs in ASP.NET Core: The Complete Guide for wiring that up end to end.

Security Options and Hardening#

The defaults are conservative, but a few options matter for untrusted input specifically. JsonSerializerOptions.MaxDepth (default 64) caps nested object and array depth to guard against stack-overflow-style denial of service from deeply nested payloads. JsonReaderOptions.CommentHandling defaults to disallowing comments, which is correct for strict JSON parsing. AllowTrailingCommas should stay off for anything but hand-authored configuration files. When deserializing into a type with an object-typed property, avoid object in favor of a concrete JsonElement or a known DTO, since deserializing arbitrary content into object produces boxed JsonElement values that callers can misuse if they assume a specific CLR type without checking.

What's New in .NET 9 and .NET 10#

.NET 9 added several features covered above, plus two worth calling out separately: RespectNullableAnnotations, which makes the serializer enforce C# nullable reference type annotations during (de)serialization instead of only enforcing non-nullability for value types, and JsonSchemaExporter, which generates a JSON Schema document describing a type's serialization contract, useful for tool-calling schemas and OpenAPI generation.

C#
using System.Text.Json.Schema;

JsonSerializerOptions options = JsonSerializerOptions.Default;
JsonNode schema = options.GetJsonSchemaAsNode(typeof(Order));

.NET 10 tightened defaults further. JsonSerializerOptions.AllowDuplicateProperties can be set to false to reject a payload that repeats the same property name, which the specification leaves undefined and which historically let the last value silently win. JsonSerializerOptions.Strict is a new preset, read-compatible with Default, that combines UnmappedMemberHandling.Disallow, AllowDuplicateProperties = false, case-sensitive matching, and both nullable- and required-annotation enforcement into a single option for services that want to reject payloads that do not exactly match their contract. Source-generated contexts can also now specify a ReferenceHandler directly through [JsonSourceGenerationOptions(ReferenceHandler = JsonKnownReferenceHandler.Preserve)], and JsonSerializer.Deserialize gained overloads that accept a PipeReader directly, avoiding a Stream adapter when your pipeline already produces a PipeReader.

Migrating from Newtonsoft.Json#

Most application code migrates with moderate, mechanical effort rather than a rewrite. [JsonProperty] becomes [JsonPropertyName], [JsonIgnore] exists on both, and JsonConvert.SerializeObject/DeserializeObject become JsonSerializer.Serialize/Deserialize. The larger gaps are behavioral: System.Text.Json has no built-in support for [JsonConstructor]-free constructor binding ambiguity resolution the way Newtonsoft infers it, has stricter default handling of trailing commas and comments, and does not serialize non-public members or fields without opting in via IncludeFields. Custom JsonConverter implementations need rewriting against the different reader/writer API, since Newtonsoft's JsonReader/JsonWriter and System.Text.Json's Utf8JsonReader/Utf8JsonWriter are not source-compatible. Budget time for converters and for any code that relied on dynamic access over JObject, which maps to JsonNode but with a different member-access pattern.

System.Text.Json vs Newtonsoft.Json#

AspectSystem.Text.JsonNewtonsoft.Json
Performance and allocationsBuilt on UTF-8 spans, low allocationBuilt on UTF-16 TextReader, more allocation
Source generation / AOTYes, via JsonSerializerContextNo
Default in ASP.NET CoreYes, since .NET Core 3.0No, opt-in package
Polymorphism[JsonDerivedType], explicit discriminatorsTypeNameHandling, assembly-qualified names
Non-public membersOpt-in, limitedBroad support via contract resolvers
dynamic accessJsonNode indexers, not dynamicdynamic over JObject
Ecosystem maturityYounger, actively extended each releaseMature, feature-complete, in maintenance-first mode

Best Practices#

  • Reuse a single JsonSerializerOptions instance (or a JsonSerializerContext) rather than constructing one per call.
  • Adopt source generation for any type that participates in a hot path, a trimmed deployment or Native AOT.
  • Prefer Utf8JsonReader/Writer or DeserializeAsyncEnumerable for large payloads instead of loading everything into memory first.
  • Set an explicit type discriminator with [JsonDerivedType] for any type hierarchy you serialize, rather than discovering the gap at run time.
  • Keep MaxDepth at its default or lower for endpoints that accept untrusted input, and consider JsonSerializerOptions.Strict for internal service-to-service contracts.
  • Benchmark converters and options changes the way you would any other hot-path code, as explained in Benchmarking .NET Code with BenchmarkDotNet.

Common Pitfalls#

  • Creating a new JsonSerializerOptions per request or per call, which repeats contract resolution work that should happen once.
  • Forgetting [JsonInclude] or a public setter, which silently drops properties during serialization instead of throwing.
  • Assuming reflection-based (de)serialization works unchanged after trimming or Native AOT publishing, when it needs a source-generated context or explicit trimmer annotations.
  • Deserializing a polymorphic hierarchy without a discriminator, which either throws or silently loses derived-type data.
  • Treating JsonElement values as if they outlive their JsonDocument without calling Clone(), which throws once the document is disposed.
  • Copy-pasting Newtonsoft-era converters unchanged, when the reader/writer APIs are similar in spirit but not source-compatible.

Frequently Asked Questions#

Is System.Text.Json faster than Newtonsoft.Json?#

In most benchmarks, yes, particularly for high-throughput scenarios, because it operates on UTF-8 spans instead of allocating intermediate UTF-16 strings and buffers. The gap widens further with source generation, since it removes reflection from the hot path entirely. The difference matters most under load; for low-volume, non-hot-path code, both are fast enough.

When should I use source generation instead of reflection?#

Use source generation whenever the project targets Native AOT or trimming, since reflection-based metadata does not survive trimming safely without extra annotations. Even outside AOT scenarios, source generation removes first-call reflection overhead and gives you compile-time errors for unsupported types, so it is a reasonable default for new services rather than an optimization reserved for special cases.

How do I serialize a base class and get the correct derived type back?#

Annotate the base type with [JsonPolymorphic] and list every derived type with [JsonDerivedType(typeof(Derived), "discriminator")]. Without this, System.Text.Json has no type information in the JSON to know which derived type to construct, and deserializing into the base type either throws or silently returns only the base type's own members.

What is the difference between JsonNode, JsonDocument and JsonElement?#

JsonDocument parses JSON into a disposable, pooled buffer exposed through JsonElement, a lightweight read-only struct for inspecting values. JsonNode (with JsonObject/JsonArray) is a mutable, heavier tree for building or editing JSON. Choose JsonDocument/JsonElement for read-only inspection, and JsonNode when you need to construct or modify a JSON document.

Can System.Text.Json fully replace Newtonsoft.Json?#

For the large majority of applications, yes, and it is the better long-term choice given how actively it is developed and how well it fits AOT and trimming. A minority of codebases that lean heavily on Newtonsoft-specific features, such as TypeNameHandling for arbitrary polymorphism or deep dynamic access over JObject, need extra migration work rather than a drop-in swap.

Summary#

  • System.Text.Json is UTF-8- and span-based, which makes it faster and lower-allocation than string-based serializers.
  • Source generation via JsonSerializerContext removes reflection from the hot path and is close to mandatory for Native AOT and trimmed apps.
  • [JsonDerivedType] gives you polymorphic serialization with an explicit type discriminator.
  • Use JsonDocument/JsonElement for read-only access and JsonNode for building or editing JSON.
  • .NET 9 added JsonSchemaExporter and nullable-annotation enforcement; .NET 10 added AllowDuplicateProperties, the Strict preset and PipeReader support.
  • Migrating from Newtonsoft.Json is mostly mechanical, except for custom converters and dynamic-based code.

Further Reading#