C# & .NET (C# 14, .NET 10 LTS) Module 19 – Files, JSON, XML, dates, databases and HTTP
JSON in C#: System.Text.Json and source generation
Turn C# records into JSON and back with System.Text.Json, generate the code at build time for Native AOT, and read untrusted JSON strictly.
What you will learn
- Serialise records to JSON and back with System.Text.Json, choosing names and enum formats with options
- Generate serialisation code with a JsonSerializerContext, and use it with reflection switched off
- Read polymorphic payloads from an allowlist of types, and detect the kinds that are not on it
- Configure strict options that turn duplicate, unknown, null and missing members into errors
Before you start
What you’ll learn · Before you start
What you will learn
- Serialise records to JSON and back with System.Text.Json, choosing names and enum formats with options
- Generate serialisation code with a JsonSerializerContext, and use it with reflection switched off
- Read polymorphic payloads from an allowlist of types, and detect the kinds that are not on it
- Configure strict options that turn duplicate, unknown, null and missing members into errors
Before you start
On this page
A booking service received {"pnr":"A1","pnr":"B2"} and stored B2. Another received {"passangers":2}, with one
letter wrong, and stored a booking for no passengers. Neither raised an error, because System.Text.Json’s default
options let the last duplicate win and skip a name they cannot match.
You will turn records into JSON and back, choose how names and enums are written, generate the serialisation code when the program is built, and see what the defaults let through, with the options that refuse it.
JsonSerializer turns a record into JSON and back, and the options pick the names
roundtrip.cs writes a Booking record with the default options and with the Web defaults, reads it back, and then
reads JSON whose names differ in case:
// A booking record to JSON and back with System.Text.Json: the default options, the Web defaults, enums as strings,
// and a source-generated context that does the same work without reflection.
using System.Text.Json;
using System.Text.Json.Serialization;
var booking = new Booking("4521873690", 12951, new DateOnly(2026, 12, 5), Coach.ThirdAc, 2, 3430.50m, null);
// 1. Default options: property names as written in C#, enums as numbers, null kept.
Console.WriteLine($"default: {JsonSerializer.Serialize(booking)}");
// 2. The Web defaults: camelCase names, and reading that ignores case and accepts numbers in quotes.
var web = new JsonSerializerOptions(JsonSerializerDefaults.Web) { Converters = { new JsonStringEnumConverter() } };
string json = JsonSerializer.Serialize(booking, web);
Console.WriteLine($"web: {json}");
// 3. Back again. A record compares by value, so the copy equals the original.
Booking back = JsonSerializer.Deserialize<Booking>(json, web)!;
Console.WriteLine($"round trip equal: {back == booking}");
// 4. What the Web defaults accept that the default options do not: names in another case, numbers in quotes.
string loose = """{"PNR":"4521873690","train":"12951","date":"2026-12-05","coach":"ThirdAc","passengers":2,"fare":3430.50}""";
Booking fromWeb = JsonSerializer.Deserialize<Booking>(loose, web)!;
Console.WriteLine($"web options read: pnr {fromWeb.Pnr}, train {fromWeb.Train}, coach {fromWeb.Coach}");
Booking fromDefault = JsonSerializer.Deserialize<Booking>(loose)!;
Console.WriteLine($"default options read: pnr {fromDefault.Pnr ?? "null"}, train {fromDefault.Train}, coach {fromDefault.Coach}, and no exception");
try { JsonSerializer.Deserialize<Booking>("""{"Pnr":"4521873690","Train":"12951"}"""); }
catch (JsonException e) { Console.WriteLine($"default options, \"Train\":\"12951\": JsonException: {e.Message[..e.Message.IndexOf(" Path", StringComparison.Ordinal)]}"); }
// 5. The source-generated context: the same JSON, from code the compiler wrote when it built this program.
string generated = JsonSerializer.Serialize(booking, BookingJson.Default.Booking);
Console.WriteLine($"generated: {generated}");
Console.WriteLine($"same as web: {generated == json}; back: {JsonSerializer.Deserialize(generated, BookingJson.Default.Booking) == booking}");
public enum Coach { Sleeper, ThirdAc, SecondAc, FirstAc }
public record Booking(string Pnr, int Train, DateOnly Date, Coach Coach, int Passengers, decimal Fare, string? Note);
[JsonSourceGenerationOptions(JsonSerializerDefaults.Web, UseStringEnumConverter = true)]
[JsonSerializable(typeof(Booking))]
public partial class BookingJson : JsonSerializerContext; Output
default: {"Pnr":"4521873690","Train":12951,"Date":"2026-12-05","Coach":1,"Passengers":2,"Fare":3430.50,"Note":null}
web: {"pnr":"4521873690","train":12951,"date":"2026-12-05","coach":"ThirdAc","passengers":2,"fare":3430.50,"note":null}
round trip equal: True
web options read: pnr 4521873690, train 12951, coach ThirdAc
default options read: pnr null, train 0, coach Sleeper, and no exception
default options, "Train":"12951": JsonException: The JSON value could not be converted to Booking.
generated: {"pnr":"4521873690","train":12951,"date":"2026-12-05","coach":"ThirdAc","passengers":2,"fare":3430.50,"note":null}
same as web: True; back: True
Recorded with .NET SDK 10.0.401 (runtime 10.0.12) on macOS 26 arm64. To run it yourself: dotnet run roundtrip.cs
The default options wrote the C# names as they are, "Pnr" and "Coach":1, the enum as a number. The Web defaults,
JsonSerializerDefaults.Web, wrote camelCase, and a JsonStringEnumConverter turned the coach into "ThirdAc". The
DateOnly became "2026-12-05" and the fare kept its two decimals. Reading back gave a record equal to the original,
because the serializer fills a record through its constructor:
The drawing shows the whole scene; its 5 steps are listed below.
Every step as text
- The text. The input is the JSON that the Web options wrote: seven members such as "pnr", "coach" and "fare". The serializer reads it once, from the start, without loading it into objects first.
- One member at a time. A reader walks the text token by token: the start of the object, then a name and its value, then the next. "date" comes with a date written as a string, "fare" with the number 3430.50.
- Names to parameters. Booking is a positional record, so the serializer fills its constructor. Each JSON name is matched to a parameter; with the Web options the match ignores case, so "PNR" would find Pnr too. A name that matches nothing is skipped.
- Values to types. Each value is converted to its parameter's type: "ThirdAc" to the enum Coach.ThirdAc by the string enum converter, the date string to a DateOnly, 3430.50 to a decimal that keeps its two decimal places.
- One call to the constructor. With every value converted, the serializer calls the constructor once. A parameter whose member never appeared gets its default, 0 or null, which is why a misspelt name goes unnoticed unless you ask for strict reading.
The Web defaults also read names in any case and numbers written as strings. The default options read the same JSON
without an exception, and with every value missing: pnr null, train 0, since their names must match exactly; and
even with the exact name, "Train":"12951" was a JsonException for them, a number in quotes. Write
and read with the same options, and keep them in one static instance: the documentation advises reusing options
instead of creating them for every call.
Checkpoint
Check: Options, names and enums
Source generation writes the serialisation code when the program is built
By default the serializer inspects your types by reflection while the program runs. A
JsonSerializerContext: A partial class that the System.Text.Json source generator fills in while the program is built, for the types named in its [JsonSerializable] attributes, so that serialising them needs no reflection. moves that work to the build: BookingJson names
Booking with [JsonSerializable], and the source generator writes the code that reads and writes it:
The drawing shows the whole scene; its 4 steps are listed below.
Every step as text
- The attribute. roundtrip.cs declares BookingJson as a partial class deriving from JsonSerializerContext, with [JsonSerializable(typeof(Booking))] and the Web options in [JsonSourceGenerationOptions].
- Code written at build time. While the program is built, the source generator reads the attribute and the Booking record and writes the other half of BookingJson: what Booking's members are, their types and how to write and read each one.
- A call at run time. When the program runs, Serialize(booking, BookingJson.Default.Booking) passes the generated JsonTypeInfo instead of options, so the serializer asks the context about Booking instead of inspecting the type by reflection.
- The same JSON. The context writes the same JSON as the Web options with the string enum converter: roundtrip.cs printed True when it compared the two. Nothing in this run needed reflection.
roundtrip.cs serialised through BookingJson.Default.Booking and got the same JSON as the Web options,
same as web: True. Microsoft’s documentation explains why this matters: reflection-based serialization can break
Native AOT and trimmed apps, and source generation gives them the metadata at build time. The generated code is
ordinary C#, compiled into your own assembly. It has two modes: metadata, which every kind of call can use, and
serialization optimization, a faster path for writing only.
Checkpoint
Check: A source-generated context
The default options trust the input more than a service should
System.Text.Json refuses JSON that is not well formed, but it accepts well-formed input that a careful service would
not. Each risk has an option that turns it into a JsonException:
| In the JSON | Default options | Refused by |
|---|---|---|
| a member twice | the last one wins | AllowDuplicateProperties = false |
| a name that matches nothing | skipped | UnmappedMemberHandling.Disallow |
null for a non-nullable string |
accepted | RespectNullableAnnotations |
| a constructor parameter missing | its default value | RespectRequiredConstructorParameters |
| arrays nested very deep | up to 64 levels | a lower MaxDepth |
.NET 10 adds JsonSerializerOptions.Strict, a preset that turns on the first four together. Payloads of several kinds
are read with [JsonPolymorphic] and [JsonDerivedType]: the JSON names its kind with a
type discriminator: A member such as "$type": "card" in polymorphic JSON that says which derived type to create. System.Text.Json accepts only the values declared with [JsonDerivedType]. such as "$type": "card", chosen only from the list you declare.
Key idea
The default options read well-formed JSON generously. For input from outside, decide which of duplicates, unknown names, nulls and missing members you refuse, and let the input choose a type only from your own list.
Checkpoint
Check: What the default options accept
Polymorphic payloads from an allowlist, and the failures it stops Pro: opens with a Pro, Premium or Ultimate pass
One endpoint often accepts several kinds of the same thing. This part reads payments of three kinds through one abstract record, shows what happens to every malformed variant, and finds the bug in how the JSON was written.
In this part:
- Five payloads, three outcomes
- Why the input must never name the type itself
- The bug in writing, and how to avoid it
- Adding a kind without breaking readers
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
Strict input: duplicate, misspelt and missing members, nulls and depth, measured Pro: opens with a Pro, Premium or Ultimate pass
The table above listed the options that refuse risky input. This part runs each one against the input it is for, shows the message it gives, and tests the Strict preset of .NET 10, including the one case it does not cover.
In this part:
- Five inputs, each read twice
- The Strict preset, and what it leaves to you
- Where the checks go in a service
1 runnable example (C#) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Source generation with reflection switched off, and a lab that puts it together Pro: opens with a Pro, Premium or Ultimate pass
Trimmed and Native AOT apps run with reflection-based serialization switched off. This part switches it off in an ordinary run, so that every call that still depends on reflection fails at once, then builds a strict, generated reader for a booking file in a lab.
In this part:
- What fails when reflection is off, measured
- A lab: a strict, generated reader for a booking file
2 runnable examples (C#) · about 4 minutes
Opening this part with your pass…Pro part. It opens with the same pass as the part above.
Practise
Three ideas from the first sections of this lesson, a few questions on each: options and names, the generated context, and what the defaults accept.
Practice that adapts to you
Practise this lesson
7 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Fresher · What does JsonSerializerDefaults.Web change?
It creates options with the settings web APIs usually expect. Property names are written in camelCase, so Pnr
becomes "pnr"; names are matched without regard to case when reading, so "PNR" also fills Pnr; and numbers may
be read from strings, so "12951" fills an int. Everything else stays as in the default options: enums are still
numbers unless you add a string enum converter, and unknown members are still skipped.
-
Mid to senior
A partner's payment webhook carried "amount" twice with different values. Your service, which reads it with the default System.Text.Json options, charged one value, and the partner's own records show the other. Explain what happened, why it is a security problem, and how you would harden the webhook's deserialisation.
-
Senior to staff
One endpoint must accept three kinds of payment instruction, each with its own fields. A teammate proposes putting the .NET type name in the JSON and creating that type. What do you propose instead, and which failures must the design handle?
-
Mid to senior
A service works under dotnet run, but once published with Native AOT its JSON endpoints fail at run time. What is the likely cause, how do you find every affected call before release, and how do you fix them?
Key takeaways
- JsonSerializer writes a record’s properties and reads it back through its constructor, and the options decide the names: the Web defaults write camelCase and read names in any case, while the default options silently miss names in another case.
- A JsonSerializerContext lets the source generator write the serialisation code at build time, which trimmed and Native AOT apps need.
- The default options accept duplicate, unknown, null and missing members; each has an option that refuses it, and polymorphic JSON chooses its type only from the kinds you list.
Terms in this lesson
- JsonSerializerContext
- A partial class that the System.Text.Json source generator fills in while the program is built, for the types named in its [JsonSerializable] attributes, so that serialising them needs no reflection.
- type discriminator
- A member such as "$type": "card" in polymorphic JSON that says which derived type to create. System.Text.Json accepts only the values declared with [JsonDerivedType].
References
- Serialize and deserialize JSON using C# (Microsoft Learn)
- JsonSerializerDefaults enum (Microsoft Learn)
- How to instantiate JsonSerializerOptions instances (Microsoft Learn)
- How to use source generation in System.Text.Json (Microsoft Learn)
- How to serialize properties of derived classes (Microsoft Learn)
- What's new in .NET libraries for .NET 10 (Microsoft Learn)
- JsonSerializerOptions.MaxDepth property (Microsoft Learn)
- BinaryFormatter migration guide (Microsoft Learn)
- CWE-502: Deserialization of Untrusted Data (MITRE)
- Deserialization Cheat Sheet (OWASP Cheat Sheet Series)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress