System.Text.Json: Serialization and Options
Objective
System.Text.Json looks simple (Serialize, Deserialize) and its defaults are
deliberately conservative, which means most surprises come from what it quietly
does not do: a property whose name differs by case is skipped and left at its
default, a missing constructor argument becomes 0 or null, a derived type is
written as its base. This concept is about the JsonSerializerOptions object
that controls all of that: why you must reuse it (with a measurement of what
happens when you do not), what JsonSerializerDefaults.Web and the .NET 10
Strict preset change, naming policies, JsonIgnoreCondition, numbers and
enums, polymorphism with [JsonDerivedType], required members, constructors,
and null handling. Source generation, JsonNode and the low-level reader are in
the next concept.
Use Cases
- Choosing one set of options for an HTTP API so every endpoint and every client agrees on casing, enums and nulls.
- Making a configuration or message reader fail loudly on typos and missing fields instead of filling in defaults.
- Serializing a class hierarchy (shapes, events, commands) and getting the right subtype back.
- Finding out why a field you sent never arrives, or why a hot path allocates tens of kilobytes per serialization.
Deep Dive
The defaults are lenient on read and silent on loss
All numbers below were produced on .NET 10.0.0 (SDK 10.0.100), macOS 27.0.1, Apple M4 Max. Start with a positional record and a payload with the wrong casing:
csharprecord Order(int Id, string Customer, decimal Total, string? Note, Status Status, DateOnly Date);
var json = """{"ID":2,"customer":"Bo","total":1,"status":"Pending"}""";
JsonSerializer.Deserialize<Order>(json);
// Order { Id = 0, Customer = , Total = 0, Note = , Status = Pending, Date = 01/01/0001 }No exception, and not one property was bound: the default matching is
case-sensitive, so every member silently stayed at its default (Status shows
Pending only because that is the enum's zero value). This is the single most
common serializer bug report, and it is why the options matter more than the
call.
JsonSerializerDefaults.Web (or the shared JsonSerializerOptions.Web
singleton, available since .NET 9) changes three things: camelCase names on
write, case-insensitive names on read, and AllowReadingFromString for numbers.
The docs list exactly these three, and reading the properties back confirmed
them. With the Web options the same payload no longer loses the keys, though it
now fails on "status":"Pending" because enums are numbers unless you add a
converter (below). ASP.NET Core uses these web defaults for its own endpoints.
Reuse the options instance, and know what a "fresh" one costs
The documentation says to reuse JsonSerializerOptions instead of creating one
per call, because the type metadata is cached on the instance. I measured it with
a 7-property record, 20,000 calls on one shared instance versus new instances:
| Options per call | Time per Serialize |
Allocated per call |
|---|---|---|
| one shared instance | 1.0 us | about 540 B |
new JsonSerializerOptions(Web) each time |
1.0 us | about 770 B |
new options + new JsonStringEnumConverter() each time |
58 us | about 33 KB |
| new options + a new custom naming policy object each time | 57 us | about 33 KB |
The second row surprised me: creating a fresh instance with identical settings
was not slower. The reason is in the runtime source (JsonSerializerOptions.Caching.cs,
v10.0.0): once an options instance is used, it looks up a shared caching context
among tracked ones, matched by a conservative equality that compares nearly every
setting, with the type-info resolver and each converter compared by reference.
Identical plain settings share metadata. A new converter or policy object does
not match anything, so every call rebuilds the metadata, which is the 58x and the
33 KB. The tracking array holds a limited number of entries (64 in that source),
so do not rely on this cache as a design. The rule that holds regardless: build
the options once, in a static field or the DI container, and pass it everywhere.
An options instance becomes read-only on first use. Changing it afterwards throws
InvalidOperationException ("read-only or has already been used"). To derive a
variant, copy it: new JsonSerializerOptions(shared) { WriteIndented = true }.
The copy does not carry over the metadata cache, per the docs, so it is a new
instance for cost purposes. JsonSerializerOptions.Default and .Web are
read-only singletons.
Names, casing and dictionary keys
PropertyNamingPolicy rewrites member names on write and is also applied when
matching on read. Built-in policies: CamelCase, SnakeCaseLower,
SnakeCaseUpper, KebabCaseLower, KebabCaseUpper. For FirstName, LastName,
HTTPCode the results were:
csharp// CamelCase {"firstName":"a","lastName":"b","httpCode":"c"}
// SnakeCaseLower {"first_name":"a","last_name":"b","http_code":"c"}
// KebabCaseUpper {"FIRST-NAME":"a","LAST-NAME":"b","HTTP-CODE":"c"}Two details that bite: the naming policy does not touch dictionary keys (a key
FirstKey stayed FirstKey; you need DictionaryKeyPolicy for that), and a
[JsonPropertyName("Custom-Name")] attribute always wins over any policy.
Ignoring members and the null/default family
csharprecord Ign(int Count, string? Note, string Keep, [property: JsonIgnore] string Secret);
new Ign(0, null, "x", "y");
// default {"Count":0,"Note":null,"Keep":"x"}
// WhenWritingNull {"Count":0,"Keep":"x"}
// WhenWritingDefault {"Keep":"x"}JsonIgnoreCondition.WhenWritingDefault drops everything that equals its type's
default, which includes the number 0 (see Count above). That is almost never what an
API wants for a real zero, so prefer WhenWritingNull globally and put
[JsonIgnore(Condition = ...)] on the one property that needs the other rule.
[JsonIgnore] alone means "never", on read and write.
Visibility rules matter for round trips. Public fields are ignored unless
IncludeFields = true (or [JsonInclude]). A property with a private setter is
skipped on read (it stayed 0) unless it has [JsonInclude], and a get-only
property is never filled. Unknown JSON members are dropped silently by default.
Numbers and enums
long and decimal are written as JSON numbers, so a long.MaxValue reaches a
JavaScript client rounded; JsonNumberHandling.WriteAsString produces
{"L":"1","D":"2","I":"3"} for that case. Reading a quoted number into a long
throws by default and works with AllowReadingFromString, which the Web preset
turns on. NaN and infinity cannot be written unless you opt in with
AllowNamedFloatingPointLiterals (otherwise ArgumentException), and 1.5
read into an int is a JsonException, not a truncation. A double writes
0.1 + 0.2 as 0.30000000000000004 while decimal writes 0.3.
Enums are numbers by default (Shipped becomes 1), and the default reader
accepts any integer, even an undefined one: 7 into a two-member enum gave back
7 with no error. Add JsonStringEnumConverter to write names, and pass
allowIntegerValues: false to reject numbers (it then threw on 7). The same
converter, built with a naming policy, matched "shipped" and wrote it back in
camelCase, and a [Flags] value became "Read, Write". For trimmed and AOT
builds use the generic [JsonConverter(typeof(JsonStringEnumConverter<Level>))]
on the enum, as the next concept explains.
Polymorphism with [JsonDerivedType]
Without annotation, System.Text.Json serializes the static type you ask for.
Serialize<Animal>(new Dog("Rex", 4)) wrote {"Name":"Rex"} and dropped
Legs, while Serialize<object>(...) wrote {"Legs":4,"Name":"Rex"}.
Deserializing always creates the declared type. To round-trip a hierarchy,
declare the derived types and, for reading, a discriminator:
csharp[JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")]
[JsonDerivedType(typeof(Circle), "circle")]
[JsonDerivedType(typeof(Square), "square")]
abstract record Shape;
record Circle(double Radius) : Shape;
record Square(double Side) : Shape;
JsonSerializer.Serialize<Shape>(new Circle(2)); // {"kind":"circle","Radius":2}
JsonSerializer.Serialize(new Circle(2)); // {"Radius":2} (static type is Circle)
JsonSerializer.Deserialize<Shape>("""{"kind":"square","Side":5}"""); // Square { Side = 5 }The default discriminator name is $type. Three behaviors I confirmed: an
unknown value throws JsonException; the discriminator must come first in the
object, and {"Side":5,"kind":"square"} fails with a NotSupportedException
whose message ("must specify a type discriminator") is misleading, because the
discriminator is there but late. Setting AllowOutOfOrderMetadataProperties = true
fixes that, at the cost of buffering during streaming reads, as the docs warn.
Finally, a runtime subtype you did not declare (Kitten : Cat, only Cat
declared) throws NotSupportedException on write unless the base opts in with
UnknownDerivedTypeHandling = FallBackToBaseType, which wrote {"Name":"Mia"}
and so lost the extra member.
required, constructors and nulls
Three things control "must be present". The C# required modifier and
[JsonRequired] mean the same to the serializer (the docs say both map to
JsonPropertyInfo.IsRequired) and both throw JsonException when the member is
absent. Constructor parameters are the gap: a positional record with
Pos(string Name, int Age) read from {"Name":"x"} produced Age = 0 with no
error, until RespectRequiredConstructorParameters = true made it throw
("missing required properties including: 'Age'"). A parameter with a default
value (int Age = 7) is optional and gets that value.
Nulls have a separate switch. By default a null for a non-nullable string
is accepted, and so is a missing property: the annotation is ignored. With
RespectNullableAnnotations = true, {"Name":null} throws, and so does
serializing a null that a property says cannot be null. null for a plain
int always fails, and null for int? is fine. Note that required plus a
null value still passes without RespectNullableAnnotations: presence and
nullness are checked independently.
The strict options in .NET 10
.NET 10 adds JsonSerializerOptions.Strict, a read-only preset. The "What's new
in .NET libraries for .NET 10" page lists it as: disallow unmapped members,
disallow duplicate properties, keep case-sensitive matching, and enable
RespectNullableAnnotations and RespectRequiredConstructorParameters. Reading
the properties confirmed Unmapped=Disallow Dup=False Nullable=True ReqCtor=True CaseInsensitive=False. Behavior on the earlier payloads:
csharpDeserialize<Person>("""{"Name":"a","Age":1,"Extra":1}""", Strict); // JsonException: 'Extra' could not be mapped
Deserialize<Person>("""{"Name":"a","Name":"b","Age":1}""", Strict); // JsonException: Duplicate property 'Name'
Deserialize<Person>("""{"name":"a","AGE":1}""", Strict); // JsonException (case-sensitive, unmapped)
Deserialize<Person>("""{"Name":"a","Age":1}""", Strict); // Person { Name = a, Age = 1 }Without it the same duplicate payload silently kept the last value (b).
AllowDuplicateProperties = false is also available on its own, and the docs
note the same switch exists on JsonDocumentOptions. The docs describe Strict
as read-compatible with Default: what Default wrote, Strict reads, and
Strict read back a record with a polymorphic property written by Default.
Start from it with
new JsonSerializerOptions(JsonSerializerOptions.Strict) { ... } and add only
what you need, such as WriteIndented or a naming policy.
Smaller controls worth knowing
[JsonPropertyOrder] changes write order (lower first, default 0).
[JsonExtensionData] on a Dictionary<string, JsonElement> captures unmapped
members and writes them back. By default a list property with an initializer
is replaced on read; PreferredObjectCreationHandling = Populate appends into
the existing list instead (["a","b"] plus ["c"] gave ["a","b","c"]).
ReadCommentHandling = Skip and AllowTrailingCommas = true are needed for
config-style JSON: comments and a trailing comma both throw by default.
Trade-offs
- Strict catches contract drift and rejects harmless extras. A client that
adds a field breaks a
Strictreader. Good for configuration and internal messages, risky for a public API you do not control.csharpDeserialize<Person>("""{"Name":"a","Age":1,"NewField":true}""", Strict); // JsonException - Web defaults trade correctness for forgiveness. Case-insensitive matching and quoted numbers accept sloppy clients, and hide client bugs. Decide per boundary, not per project.
WhenWritingDefaultconflates "absent" with "zero". It removes a real0(Countvanished in the example above). UseWhenWritingNullglobally and be explicit per property.- Polymorphism ties the wire format to your type names. Discriminator strings
are a contract: renaming a class is safe only because you chose
"circle"instead of relying on the type name, and a new subtype breaks old readers that throw on unknown discriminators. - Options reuse is a lifecycle decision. One static instance is fastest and immutable after first use, so late changes (per-tenant naming, say) need a copy. A copy that carries a new converter instance pays the metadata cost again.
- Records and
requiredare checked differently.requiredand[JsonRequired]are enforced by default, positional parameters are not. Relying on a record's constructor for validation needsRespectRequiredConstructorParameters, orStrict.
Documentation Links
- How to instantiate JsonSerializerOptions, reuse and Web defaults, Microsoft Learn
- What's new in .NET libraries for .NET 10, Serialization, Microsoft Learn
- Serialize properties of derived classes, Microsoft Learn
- Require properties for deserialization, Microsoft Learn
- Customize property names and values, Microsoft Learn
- Handle overflow or missing members, Microsoft Learn
- Ignore properties, Microsoft Learn