Skip to content

System.Text.Json — Serialization Deep

Key Points

  • System.Text.Json is the modern default. Newtonsoft.Json (Json.NET) still works but is legacy — use STJ for new code.
  • JsonSerializerContext (source generator) replaces runtime reflection with compile-time codegen — faster, smaller binaries, AOT-safe.
  • Polymorphism via [JsonDerivedType(typeof(Dog), "dog")] on a base class — STJ writes a $type discriminator.
  • Custom converters via JsonConverter<T> — full control over read/write.
  • required properties + JsonSerializerOptions.RespectRequiredConstructorParameters = true enforce mandatory fields at deserialization.
  • Hot-path JSON in .NET: prefer Utf8JsonReader / Utf8JsonWriter for streaming; prefer source-gen for typed serialization.
  • Don't trust JsonElement blindly — it allocates. For performance-critical paths, deserialize to typed objects.

Concepts (deep dive)

The two modes of System.Text.Json

Reflection-based (default; works without setup):

var json = JsonSerializer.Serialize(customer);
var c = JsonSerializer.Deserialize<Customer>(json);

The serializer reflects on Customer at runtime to find properties, constructors, attributes. Won't work in NativeAOT. Marked [RequiresUnreferencedCode].

Source-generator-based (JsonSerializerContext):

[JsonSerializable(typeof(Customer))]
[JsonSerializable(typeof(List<Customer>))]
internal partial class AppJsonContext : JsonSerializerContext { }

var json = JsonSerializer.Serialize(customer, AppJsonContext.Default.Customer);
var c = JsonSerializer.Deserialize(json, AppJsonContext.Default.Customer);

The generator emits all the serialization code at compile time. AOT-safe; faster than reflection (often 2-3×).

Two sub-modes within source-gen: - Metadata mode (default): generator emits type info; runtime uses it (smaller code). - Serialization mode: generator emits direct Utf8JsonWriter calls (largest, fastest, no reflection at all).

Configuration with JsonSerializerOptions

var options = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    PropertyNameCaseInsensitive = true,
    WriteIndented = false,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    NumberHandling = JsonNumberHandling.AllowReadingFromString,
    AllowTrailingCommas = true,
    ReadCommentHandling = JsonCommentHandling.Skip,
    Converters = { new JsonStringEnumConverter() },
};

⚠️ Cache JsonSerializerOptions. Constructing/configuring is expensive; reuse instances. The library treats them as immutable after first use (you can't change settings after the first serialize call).

Source-gen with options

[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    GenerationMode = JsonSourceGenerationMode.Default)]
[JsonSerializable(typeof(Customer))]
internal partial class AppJsonContext : JsonSerializerContext { }

JsonSourceGenerationMode: - Metadata — generator emits type info. - Serialization — generator emits direct writer calls. - Default — both (run-time picks).

Polymorphism with [JsonDerivedType]

[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
[JsonDerivedType(typeof(Dog), "dog")]
[JsonDerivedType(typeof(Cat), "cat")]
public abstract class Animal
{
    public string Name { get; init; } = "";
}

public class Dog : Animal { public bool LikesWalks { get; init; } }
public class Cat : Animal { public bool LikesNaps { get; init; } }

Serializing new Dog { Name = "Rex", LikesWalks = true }:

{ "kind": "dog", "Name": "Rex", "LikesWalks": true }

Deserializing reads kind and constructs the right derived type. Closed-world (only the listed derived types).

Custom converters

public class DateOnlyConverter : JsonConverter<DateOnly>
{
    public override DateOnly Read(ref Utf8JsonReader r, Type t, JsonSerializerOptions o)
        => DateOnly.Parse(r.GetString()!, CultureInfo.InvariantCulture);

    public override void Write(Utf8JsonWriter w, DateOnly v, JsonSerializerOptions o)
        => w.WriteStringValue(v.ToString("yyyy-MM-dd"));
}

var options = new JsonSerializerOptions { Converters = { new DateOnlyConverter() } };

For complex objects, override JsonConverter<T>.Read/Write to drive the reader/writer manually. Use Utf8JsonReader.TokenType to navigate.

Utf8JsonReader / Utf8JsonWriter — streaming low-level

ReadOnlySpan<byte> json = "{ \"name\": \"Ada\", \"age\": 36 }"u8;
var reader = new Utf8JsonReader(json);

string? name = null;
int age = 0;

while (reader.Read())
{
    if (reader.TokenType == JsonTokenType.PropertyName)
    {
        var prop = reader.GetString();
        reader.Read();   // move to value

        if (prop == "name") name = reader.GetString();
        else if (prop == "age") age = reader.GetInt32();
    }
}

Utf8JsonReader is a ref struct — stack-only, zero-allocation. Use for streaming parsing where you want maximum perf. The trade-off: more code than typed deserialization.

Utf8JsonWriter is the writer counterpart — writes to an IBufferWriter<byte> or Stream.

required properties + deserialization

public class Config
{
    public required string Endpoint { get; init; }
    public string? UserAgent { get; init; }
}

// JSON missing "Endpoint" — STJ throws JsonException
var c = JsonSerializer.Deserialize<Config>("""{ "UserAgent": "ua/1" }""");

Modern STJ honors required — deserialization fails if a required property isn't present in the JSON. This is the cleanest way to enforce input contract.

[JsonIgnore] and conditional ignoring

public class Customer
{
    public string Name { get; set; } = "";

    [JsonIgnore]
    public string InternalNote { get; set; } = "";

    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
    public int Age { get; set; }
}

JsonIgnoreCondition: - Always - Never - WhenWritingDefault (zeros for value types, null for ref types) - WhenWritingNull (refs only)

JsonElement and JsonNode for dynamic JSON

JsonElement el = JsonSerializer.Deserialize<JsonElement>(json);
string name = el.GetProperty("name").GetString()!;

// JsonNode (DOM-style, mutable)
JsonNode node = JsonNode.Parse(json)!;
node["age"] = (int)node["age"]! + 1;
string updated = node.ToJsonString();

JsonElement is read-only. JsonNode is a mutable DOM (subclasses: JsonObject, JsonArray, JsonValue).

⚠️ Both allocate. Use sparingly in hot paths.

Why Newtonsoft is legacy

  • No source-gen path — won't work in NativeAOT.
  • Slower for typed serialization (reflection-heavy without source-gen).
  • Mature but not actively evolving.
  • Different defaults (e.g., property name casing, missing-property handling).
  • Doesn't natively support Span<T>/UTF-8 byte-level parsing.

That said, Newtonsoft has features STJ lacks (e.g., JsonProperty(Order = N), complex contract resolvers, more permissive parsing). Don't rip out Newtonsoft just to migrate; do it as part of natural code churn or AOT readiness.

Performance comparison

Scenario Reflection STJ Source-gen STJ Newtonsoft
Typed serialize baseline 2-3× faster ~2× slower than reflection STJ
Typed deserialize baseline 2-3× faster ~2× slower than reflection STJ
Streaming parse n/a (use Utf8JsonReader) n/a JsonTextReader — slower
Allocations many (per-property reflection) minimal (source-gen) many

JsonSerializerOptions.Web

var json = JsonSerializer.Serialize(customer, JsonSerializerOptions.Web);

A pre-configured options instance matching ASP.NET Core defaults: camelCase, case-insensitive, etc. Useful for matching minor settings.


How it works under the hood

JsonSerializer.Serialize<T> reflects on T (or uses generated metadata) to discover writable properties, constructor parameters, attributes. It builds a "type info" cache keyed on (T, options). Subsequent calls reuse the cache — that's why options instances are immutable after first use.

Source generators emit a partial JsonSerializerContext with one property per [JsonSerializable(typeof(T))]. Each property is a JsonTypeInfo<T> precomputed at compile time — no reflection needed.

Utf8JsonReader is a ref struct over ReadOnlySpan<byte>. It maintains internal state (position, current token) and emits tokens lazily. Zero allocation.

The serializer's hot loop calls into Utf8JsonWriter directly — same writer interface used by your custom converters.


Code: correct vs wrong

❌ Wrong: constructing JsonSerializerOptions per call

public string Serialize<T>(T value)
    => JsonSerializer.Serialize(value, new JsonSerializerOptions { /* ... */ });
// New options each call; no warmup; no cache reuse

✅ Correct: cache the options

private static readonly JsonSerializerOptions _options = new()
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};

public string Serialize<T>(T value) => JsonSerializer.Serialize(value, _options);

❌ Wrong: relying on reflection in NativeAOT

JsonSerializer.Serialize(customer);   // ❌ AOT warning: RequiresUnreferencedCode

✅ Correct: source-gen context

JsonSerializer.Serialize(customer, AppJsonContext.Default.Customer);

❌ Wrong: JsonElement in a hot path

public string GetName(string json)
{
    var el = JsonSerializer.Deserialize<JsonElement>(json);
    return el.GetProperty("name").GetString()!;
}

✅ Correct: typed deserialization

record Person(string Name);

public string GetName(string json)
    => JsonSerializer.Deserialize(json, AppJsonContext.Default.Person)!.Name;

❌ Wrong: not handling null from Deserialize

var c = JsonSerializer.Deserialize<Customer>(json);
c.Name.ToUpper();   // ❌ NRE if json is "null" literal

✅ Correct: null-handling

var c = JsonSerializer.Deserialize<Customer>(json)
    ?? throw new InvalidOperationException("expected Customer payload");
c.Name.ToUpper();

Design patterns for this topic

Pattern 1 — "Source-gen context per app"

  • Intent: AOT-safe + faster serialization.
  • Code sketch: see "Source-gen" above.

Pattern 2 — "Custom converter for non-default types"

  • Intent: handle types STJ doesn't know.
  • Code sketch: see DateOnlyConverter above.

Pattern 3 — "Polymorphic discriminator"

  • Intent: serialize/deserialize sealed type hierarchy.
  • Code sketch: see [JsonDerivedType] above.

Pattern 4 — "required properties for input contract"

  • Intent: fail-fast on missing required fields.

Pattern 5 — "Streaming Utf8JsonReader for huge payloads"

  • Intent: zero-allocation parsing of GB-scale JSON.

Pros & cons / trade-offs

Approach Pros Cons
Reflection STJ Just works Slower; not AOT-safe
Source-gen STJ Fast; AOT-safe More setup; must declare every type
Custom JsonConverter<T> Total control More code
JsonElement/JsonNode Schema-less Allocations; slow
Utf8JsonReader direct Fastest Manual; verbose

When to use / when to avoid

  • Use source-gen STJ as default for new code.
  • Use custom converters for non-default types (DateOnly historically, custom value types, third-party types).
  • Use Utf8JsonReader for performance-critical streaming parsing.
  • Avoid Newtonsoft for new projects unless you need its specific features.
  • Avoid JsonElement in hot paths — go typed.

Interview Q&A

Q1. Why prefer System.Text.Json over Newtonsoft.Json? Faster (especially with source-gen), AOT-safe, integrated with .NET, modern API. Newtonsoft is mature but legacy.

Q2. What's JsonSerializerContext? A source generator that produces serialization code at compile time. Replaces reflection-based serialization. Required for NativeAOT.

Q3. How does polymorphism work in STJ? [JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")] + [JsonDerivedType(typeof(Derived), "discriminator")] on the base class. Serializer writes the discriminator property; deserializer uses it to pick the runtime type.

Q4. Why cache JsonSerializerOptions? Construction is expensive; the type-info cache is keyed on the options instance. New options means new cache. Reuse a single instance per app.

Q5. How do you handle DateOnly? Modern STJ (.NET 7+) supports DateOnly/TimeOnly natively. For older .NET, write a JsonConverter<DateOnly> (see code above).

Q6. What does required do for deserialization? Modern STJ fails deserialization if a required property is missing from JSON. Cleanest way to enforce input contract.

Q7. Difference between JsonElement and JsonNode? JsonElement is read-only DOM. JsonNode is mutable (with subclasses JsonObject, JsonArray, JsonValue). Both allocate.

Q8. When would you use Utf8JsonReader directly? Streaming parse of huge payloads, zero-allocation requirement, custom JSON formats. The trade-off: more verbose code.

Q9. What's JsonSourceGenerationMode.Serialization? The mode where the generator emits direct Utf8JsonWriter calls — no reflection at runtime. Largest binary; fastest serialization.

Q10. What's a [JsonExtensionData] property? A Dictionary<string, JsonElement> (or IDictionary<string, object>) on your type. Captures unknown JSON properties — deserializer puts them in your dictionary; serializer writes them out.

Q11. How do you serialize a property to a different name? [JsonPropertyName("custom_name")].

Q12. How do you handle case-sensitive JSON property names? PropertyNameCaseInsensitive = false (the default is true only when Web options apply). Or per-property [JsonPropertyName].


Gotchas / common mistakes

  • ⚠️ New JsonSerializerOptions per call — cache miss every time.
  • ⚠️ Source-gen forgetting a type — runtime "no metadata" error.
  • ⚠️ Newtonsoft attributes on STJ types — silently ignored.
  • ⚠️ null from Deserialize<T> — possible if JSON is null literal.
  • ⚠️ Polymorphism with non-sealed unknown types — error or fallback to object.
  • ⚠️ Mixing reflection STJ and source-gen STJ in AOT — reflection path will fail.
  • ⚠️ JsonElement retained beyond use — keeps the entire backing buffer alive.

Further reading