System.Text.Json: Source Generation, JsonNode and Utf8JsonReader
Objective
The serializer in the previous concept finds your types by reflection at run
time. That is convenient and has three costs: a slower first call, warnings
under trimming, and a hard failure under Native AOT. This concept covers the
alternative that moves that work to compile time (JsonSerializerContext and
[JsonSerializable]), what really changes when an app is trimmed, and the three
lower-level tools you pick when you do not want a full object model: JsonDocument,
JsonNode, and the Utf8JsonReader / Utf8JsonWriter pair, plus streaming
arrays with DeserializeAsyncEnumerable. Options and polymorphism are in the
serialization and options concept.
Use Cases
- Shipping a trimmed or Native AOT service that must serialize without reflection.
- Reading one field out of a large response without building objects for the rest.
- Editing a JSON document whose shape you do not own (add a property, keep the rest).
- Processing a multi-megabyte array element by element as it arrives.
Deep Dive
What the source generator produces
Declare a partial class that derives from JsonSerializerContext and list the
root types. Every number below is from .NET 10.0.0 (SDK 10.0.100), macOS 27.0.1,
Apple M4 Max.
csharp[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, UseStringEnumConverter = true)]
[JsonSerializable(typeof(Order))]
[JsonSerializable(typeof(Order[]))]
partial class AppJsonContext : JsonSerializerContext;
JsonSerializer.Serialize(order, AppJsonContext.Default.Order);
// {"id":1,"customer":"Ana","total":9.5,"tags":["a","b"],"status":"Shipped"}For one 5-member record the generator emitted 569 lines in 9 files (with
EmitCompilerGeneratedFiles): a JsonTypeInfo<Order> factory, a constructor
delegate, and one serialize handler per type, including int, string,
decimal, List<string>, and the enum, which were found by walking the members.
Rules that surprised me:
- Member types are discovered, collections are not. With only
Orderlisted,GetTypeInfo(typeof(Status))was found buttypeof(List<Order>)wasnull;Order[]worked because I listed it. - A type you did not list fails loudly:
InvalidOperationException: JsonTypeInfo metadata for type 'Person' was not provided by TypeInfoResolver of type 'AppJsonContext', orNotSupportedExceptionthrough an options instance. - The context has its own options.
[JsonSourceGenerationOptions]sets them at compile time, andAppJsonContext.Default.Optionsis read-only ("cannot be modified once encapsulated by a JsonSerializerContext"). A bare context uses the plain defaults: my first run read a lowercase payload with no error and bound nothing, until I addedJsonSerializerDefaults.Web. - Call the overloads that take
JsonTypeInfo<T>or a context. To keep reflection as a fallback,JsonTypeInfoResolver.Combine(AppJsonContext.Default, new DefaultJsonTypeInfoResolver())serialized the unlistedPerson, with the warnings back.
Two generation modes exist. The default (metadata) generates everything needed
for both directions. GenerationMode = Serialization (fast path) generates only
a write handler: it wrote Order fine, but reading threw "did not provide
property metadata", and a polymorphic Shape also failed to write. The docs say
polymorphism is supported in metadata mode only, so keep the default unless you
only write and profiling shows the need.
Speed, measured on a 5-member record: 0.52 us per call with the context against
0.60 us with reflection once warm, which is not why you adopt it. The measurable
gain is start-up: the first serialize plus deserialize in a fresh process took a
median of about 15.4 ms with the context and 19.9 ms with reflection (12 runs
each, framework-dependent dotnet App.dll). The other gains are in the next
section.
What changes under trimming and AOT, without publishing AOT
I did not publish Native AOT. Trimming and the analyzers are enough to see the
contract. With IsAotCompatible set, the compiler warned on these calls:
| Call | Warning |
|---|---|
Serialize(o) |
IL2026 and IL3050 |
Serialize(o, optionsWithContextResolver) |
IL2026 and IL3050 (the overload is annotated, whatever the options hold) |
Serialize(o, JsonSerializerOptions.Web) |
IL2026 and IL3050, on the call and on Web itself |
Serialize(o, ctx.Order) or Serialize(o, typeof(Order), ctx) |
none |
new JsonStringEnumConverter() |
IL3050 |
new JsonStringEnumConverter<Status>() |
none |
The rule: pass a JsonTypeInfo or a context, never an options-only overload, and
use the generic enum converter ([JsonConverter(typeof(JsonStringEnumConverter<Level>))]
on the enum, or UseStringEnumConverter = true on the context).
At run time the picture is stricter than warnings. A PublishTrimmed=true app
wrote System.Text.Json.JsonSerializer.IsReflectionEnabledByDefault: false into
runtimeconfig.json (the docs say trimming turns reflection off unless you set
the property), and a plain Serialize(o) then threw InvalidOperationException: Reflection-based serialization has been disabled. Forcing it back on with
<JsonSerializerIsReflectionEnabledByDefault>true</...> is worse in a quiet way:
for a class whose getters nothing else referenced, the trimmed app printed {}
while the same code untrimmed printed {"Name":"gear","Teeth":12}. The trimmer
removed members only reflection would have used. A context-based app published
trimmed in the same way printed the right JSON, which is the actual argument for the generator.
JsonDocument versus JsonNode versus a POCO
All three read JSON without a type of your own. JsonDocument is a read-only
view over a pooled buffer (so it is IDisposable); JsonNode is a mutable tree
of objects. The same task, summing total of rows whose status is "paid" in
a 2,000 row, 277,561 byte array, 300 repetitions:
| Approach | Time per run | Allocated per run |
|---|---|---|
| deserialize to a record array (source gen) | 2.3 ms | 1,141 KB |
JsonDocument |
1.0 ms | 0.1 KB (buffers are pooled) |
JsonNode |
1.8 ms | 2,850 KB |
Utf8JsonReader |
0.43 ms | 0 KB |
All four returned 666499.75. The reader is fastest and allocates nothing, but my
loop only works because status precedes total in every row: it keeps state
by hand. Behaviors to know:
JsonDocument:GetPropertyon a missing name throwsKeyNotFoundException(useTryGetProperty);GetInt32on a string throwsInvalidOperationException. AJsonElementused afterDisposethrowsObjectDisposedException, soClone()it first (the clone survived disposal).- Numbers keep their text:
1.10stayed1.10throughGetRawTextand throughJsonNode, and a 20 digit number read asdoublelost precision (1.2345678901234567E+19) whileGetDecimalkept12345678901234567890.123456789. JsonNode: a missing key returnsnull, sonode["nope"]["x"]is aNullReferenceException. A node has one parent: assigning an already attached node to another place threw "already has a parent"; useDeepClone().Addwith an existing key throws, the indexer replaces.- Per the docs,
JsonSerializerOptionspassed to aJsonNodeorJsonDocumentonly contributes custom converters, soDefaultIgnoreConditiondoes not apply.
Utf8JsonReader and Utf8JsonWriter
Utf8JsonReader is a forward-only ref struct over ReadOnlySpan<byte> or
ReadOnlySequence<byte>. It cannot be a field of a class, cannot cross an
await, and must be passed by ref, because it is mutable and a copy would lose
position. Walking a small object showed what Read() yields: one token each for
StartObject, PropertyName, Number, and so on, with CurrentDepth and
TokenStartIndex (byte offset). Compare names with ValueTextEquals("status"u8),
which avoids allocating and handles escapes. Skip() jumped over a nested
object to the next property.
Failures are specific: {"a":1,} gave a JsonReaderException naming the trailing
comma and its byte position, and GetInt32 on a string token threw
InvalidOperationException. For data that arrives in pieces, pass
isFinalBlock: false and the previous CurrentState, then re-feed the bytes
after BytesConsumed. Feeding 7 bytes at a time produced 14 tokens, the same as
reading the whole buffer. The buffer must be at least as large as the largest
token (docs).
Utf8JsonWriter writes to an IBufferWriter<byte> or a stream and buffers until
Flush (0 bytes visible before, 2 after for {}). It validates structure: a
number directly inside an object, without a property name, threw
InvalidOperationException; with SkipValidation = true it wrote the invalid
{1}. Its default encoder is conservative: Ação <b> + é became
Ação <b> + é, and
JavaScriptEncoder.UnsafeRelaxedJsonEscaping kept the text readable, which is
only safe when the output is never embedded in HTML.
Streaming arrays with DeserializeAsyncEnumerable
JsonSerializer.DeserializeAsyncEnumerable<T>(stream) yields each element of a
top-level array as soon as its closing brace has been read. With a pipe writing
one row every 300 ms, the rows came out at 1 ms, 303 ms, and 604 ms, not at the
end. It reads from a Stream or a PipeReader (.NET 10). It expects an array: a
single top-level object failed with a "could not be converted" error. Passing
topLevelValues: true instead reads a sequence of whitespace-separated values
(2 objects in my test). The endpoint side of this is in the async streams concept.
Trade-offs
- Source generation moves failures to compile time and adds a registration
chore. Every root type and every collection must be listed, and a forgotten
one fails at run time.csharp
[JsonSerializable(typeof(List<Order>))] // without this line, List<Order> has no metadata - The generator removes reflection, not options discipline. The context
carries its own casing and ignore rules, so a context and a shared
JsonSerializerOptionscan disagree. Set the defaults on the context. - Fast-path mode is write-only and not polymorphic. It is smaller, but a later "we also read this type" change fails at run time.
JsonDocumentis cheap and fragile. Pooled memory means disposal rules: return aClone(), never the element. Property lookup is sequential.JsonNodeis convenient and allocates. It cost 2.5x the memory of a typed read of the same payload here, for edit flexibility.Utf8JsonReaderis fast and unforgiving. Order-dependent logic, manual partial buffers, noawaitinside the loop. Use it for hot paths and for skipping large payloads, not as a default.- Numbers are text until you ask.
GetDoublecan lose digits thatGetDecimalkeeps, so choose by the meaning of the field.
Documentation Links
- How to use source generation in System.Text.Json, Microsoft Learn
- Source-generation modes, Microsoft Learn
- How to use a JSON DOM, JsonDocument and JsonNode, Microsoft Learn
- How to use Utf8JsonReader, Microsoft Learn
- How to use Utf8JsonWriter, Microsoft Learn
- What's new in .NET libraries for .NET 10, PipeReader support, Microsoft Learn