System.Text.Json — Serialization Deep
Key Points
System.Text.Jsonis 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$typediscriminator. - Custom converters via
JsonConverter<T>— full control over read/write. requiredproperties +JsonSerializerOptions.RespectRequiredConstructorParameters = trueenforce mandatory fields at deserialization.- Hot-path JSON in .NET: prefer
Utf8JsonReader/Utf8JsonWriterfor streaming; prefer source-gen for typed serialization. - Don't trust
JsonElementblindly — 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):
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 }:
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
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
✅ Correct: source-gen context
❌ 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
DateOnlyConverterabove.
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 (
DateOnlyhistorically, custom value types, third-party types). - Use
Utf8JsonReaderfor performance-critical streaming parsing. - Avoid Newtonsoft for new projects unless you need its specific features.
- Avoid
JsonElementin 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
JsonSerializerOptionsper call — cache miss every time. - ⚠️ Source-gen forgetting a type — runtime "no metadata" error.
- ⚠️ Newtonsoft attributes on STJ types — silently ignored.
- ⚠️
nullfromDeserialize<T>— possible if JSON isnullliteral. - ⚠️ Polymorphism with non-sealed unknown types — error or fallback to
object. - ⚠️ Mixing reflection STJ and source-gen STJ in AOT — reflection path will fail.
- ⚠️
JsonElementretained beyond use — keeps the entire backing buffer alive.