Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Intermediate
  • 20 minutes
  • Examples run with .NET 10.0.401
  • By MySmartCoPilot
Free core, Pro depth: 6 of 20 minutes free
Free: JsonSerializer turns a record into JSON and back, and the options pick the names · Source generation writes the serialisation code when the program is built · The default options trust the input more than a service should. With a pass: 3 in-depth parts and 3 interview answers, about 14 minutes.

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

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:

roundtrip.cs: a record with default options, Web options and a generated context C# · roundtrip/roundtrip.cs
// 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:

Interactive · stage by stage How JsonSerializer builds a Booking from JSON Step through the reading of the Web-options JSON of roundtrip.cs, from the text to the record its constructor makes.
How JsonSerializer builds a Booking from JSON, step 5 of 5. Showing The JSON text, Read member by member ("fare": 3430.50), Match each name (date to Date), Convert each value ("ThirdAc" to Coach), Call the constructor (equal to the original). In focus: Call the constructor.The JSON text7 members, camelCaseRead member by member"fare": 3430.50Match each namedate to DateConvert each value"ThirdAc" to CoachCall the constructorequal to the original

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. 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.
  2. 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.
  3. 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.
  4. 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.
  5. 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.

JSON to C# Class Paste a JSON payload and get C# classes with System.Text.Json attributes, a quick start for types like Booking.

Checkpoint

Check: Options, names and enums

  1. Question 1 of 2 What did the Web defaults, with a JsonStringEnumConverter, write for the Pnr and Coach of the booking in roundtrip.cs?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: "pnr":"4521873690" and "coach":"ThirdAc"

    The naming policy decides how member names are written and the converters decide how values are written. Read with the same options you wrote with.

  2. Question 2 of 2 The default options read {"PNR":"4521873690","train":"12951"} into Booking. What happened in roundtrip.cs?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: No exception, and nothing was read: pnr null and train 0

    Silent misses are the danger of mismatched options: the program gets a booking with default values and no error. Use one options instance for writing and reading, and strict reading where input comes from outside.

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:

Interactive · message by message What the source generator does, and when Step through a build and a run of roundtrip.cs. Your code is on the left, the compiler with its generator in the middle, the generated context on the right.
What the source generator does, and when, step 4 of 4. Messages so far: roundtrip.cs to The compiler: [JsonSerializable(Booking)]; The compiler to BookingJson: generated metadata; roundtrip.cs to BookingJson: Default.Booking; BookingJson to roundtrip.cs: the same JSON.roundtrip.csyour codeThe compilerwith the generatorBookingJsonknows Booking[JsonSerializable(Booking)]generated metadataDefault.Bookingthe same JSON

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. The attribute. roundtrip.cs declares BookingJson as a partial class deriving from JsonSerializerContext, with [JsonSerializable(typeof(Booking))] and the Web options in [JsonSourceGenerationOptions].
  2. 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.
  3. 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.
  4. 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

  1. One question Why does a Native AOT app need a JsonSerializerContext such as BookingJson?

    Choose one answer.

    Show the answer: One question

    Answer: It supplies, at build time, the metadata the serializer would otherwise find by reflection at run time

    The context moves the work of understanding your types from run time to build time, which trimming and Native AOT require and which also helps start-up.

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

  1. One question A request body says {"amount":100,"amount":1}. Which option makes the serializer refuse it?

    Choose one answer.

    Show the answer: One question

    Answer: AllowDuplicateProperties = false

    Each risky kind of input has its own option, and JsonSerializerOptions.Strict of .NET 10 turns on the ones for duplicates, unmatched names, nulls and missing constructor parameters together.

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.

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.

  1. Question 1 of 7 Which of these does JsonSerializerDefaults.Web change?

    Choose one answer.

    Show the answer: Question 1 of 7

    Answer: camelCase names, names read in any case, and numbers read from strings

    The Web defaults suit web APIs: camelCase names, case-insensitive reading and numbers from strings. Everything else stays as in the default options.

  2. Question 2 of 7 A handler creates new JsonSerializerOptions(JsonSerializerDefaults.Web) on every request. What does the documentation advise?

    Choose one answer.

    Show the answer: Question 2 of 7

    Answer: Create the options once, for example in a static field, and reuse them for every call

    One options instance per configuration, reused everywhere, is both faster and keeps writing and reading consistent.

  3. Question 3 of 7 Why could JsonSerializer create the positional record Booking, which has no parameterless constructor?

    Choose one answer.

    Show the answer: Question 3 of 7

    Answer: It matches JSON names to the parameters of the record's constructor and calls it with the converted values

    A parameter whose member is missing gets its default value, which is why missing and misspelt members go unnoticed unless you ask for strict reading.

  4. Question 4 of 7 Which attribute tells the source generator to write metadata for Booking?

    Choose one answer.

    Show the answer: Question 4 of 7

    Answer: [JsonSerializable(typeof(Booking))] on a partial class that derives from JsonSerializerContext

    The context lists its types with [JsonSerializable], and [JsonSourceGenerationOptions] gives them options, here the Web defaults with enums as strings.

  5. Question 5 of 7 A context is used only to write JSON as fast as possible. Which source generation mode was made for that?

    Choose one answer.

    Show the answer: Question 5 of 7

    Answer: Serialization optimization, the fast path for writing

    By default both modes are generated; you can choose one for a whole context or for one type.

  6. Question 6 of 7 With the default options, what happens to "pnr":null for a record member declared as string, not string?

    Choose one answer.

    Show the answer: Question 6 of 7

    Answer: It is accepted: the member is null, despite its annotation

    The compiler's nullable annotations are not checked at run time unless you ask the serializer to respect them.

  7. Question 7 of 7 With [JsonPolymorphic] and three [JsonDerivedType] attributes on Payment, what may the "$type" value of an input be?

    Choose one answer.

    Show the answer: Question 7 of 7

    Answer: Only one of the three discriminators you declared

    The type discriminator selects from an allowlist. That design keeps input from naming arbitrary types.

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.

  1. 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.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. 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?

    Model answer, Pro. First step and follow-ups

  3. 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?

    Model answer, Pro. First step and follow-ups

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.