EthicsComputerCourses
Join training +91 90532 10052

System.Text.Json in Depth

C# · Lesson 30 of 43 Intermediate 7 min read Updated

In File I/O & JSON you saved a list of records with JsonSerializer.Serialize and read it back. Real projects ask for more: an API sends pnr but your property is Number; nulls must be left out; enums should be text, not numbers; one array holds UPI and card payments; and the app must start fast in a container.

This lesson covers the parts of System.Text.Json that working .NET developers use every week: attributes and options, reading JSON without a class, polymorphism, source generation, and the strict settings added in .NET 9 and .NET 10.

Shaping the JSON with attributes and options

Attributes on a type control one type. Options passed to the serializer control every type in that call. Here is a train ticket sent to a JavaScript front end:

C#
using System.Text.Json;
using System.Text.Json.Serialization;

var ticket = new Ticket("PNR4521", "Rohtak", "Jaipur", TravelClass.Sleeper, null, 545m);

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    WriteIndented = true,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
options.Converters.Add(new JsonStringEnumConverter());

Console.WriteLine(JsonSerializer.Serialize(ticket, options));

enum TravelClass { Sleeper, ThirdAc, SecondAc }

record Ticket(
    [property: JsonPropertyName("pnr")] string Number,
    string From,
    string To,
    TravelClass Class,
    string? Berth,
    decimal Fare);
Output
{
  "pnr": "PNR4521",
  "from": "Rohtak",
  "to": "Jaipur",
  "class": "Sleeper",
  "fare": 545
}
  • JsonSerializerDefaults.Web gives camelCase names and case-insensitive reading, the same defaults ASP.NET Core uses.
  • [property: JsonPropertyName("pnr")] renames one property. The property: target is needed on a record parameter so the attribute lands on the generated property.
  • WhenWritingNull leaves out Berth because it is null. Use [JsonIgnore] on a property to leave it out always, for example a password hash.
  • JsonStringEnumConverter writes "Sleeper" instead of 0. Numbers break silently if someone reorders the enum; names do not.

Create an options object once (for example as a static readonly field) and reuse it. Building a new one for every call throws away its internal cache and is slow.

Reading JSON without a class

Sometimes you only need two values from a large or unpredictable document. JsonNode (namespace System.Text.Json.Nodes) gives an editable tree; JsonDocument gives a faster, read-only view.

C#
using System.Text.Json;
using System.Text.Json.Nodes;

string json = """{"city":"Kochi","temp":{"min":24,"max":31},"alerts":["rain"]}""";

JsonNode root = JsonNode.Parse(json)!;
string city = root["city"]!.GetValue<string>();
int max = root["temp"]!["max"]!.GetValue<int>();
Console.WriteLine($"{city}: max {max}");

root["checked"] = true;
Console.WriteLine(root.ToJsonString());

using JsonDocument doc = JsonDocument.Parse(json);
Console.WriteLine(doc.RootElement.GetProperty("alerts")[0].GetString());
Output
Kochi: max 31
{"city":"Kochi","temp":{"min":24,"max":31},"alerts":["rain"],"checked":true}
rain

The ! operators tell the compiler you know the nodes exist; if a key is missing, the indexer returns null and the next call throws. JsonDocument rents memory from a pool, so always dispose it with using.

Polymorphism: one list, several types

A payments list may contain different kinds of payment. By default the serializer only knows the declared type (Payment) and would drop the extra properties. Register the derived types and a type discriminator that is written into the JSON:

C#
using System.Text.Json;
using System.Text.Json.Serialization;

List<Payment> payments = [new UpiPayment(499m, "meera@okbank"), new CardPayment(1299m, "4242")];

string json = JsonSerializer.Serialize(payments);
List<Payment> loaded = JsonSerializer.Deserialize<List<Payment>>(json)!;

foreach (Payment p in loaded)
{
    Console.WriteLine(p);
}

[JsonDerivedType(typeof(UpiPayment), "upi")]
[JsonDerivedType(typeof(CardPayment), "card")]
abstract record Payment(decimal Amount);

record UpiPayment(decimal Amount, string Vpa) : Payment(Amount);
record CardPayment(decimal Amount, string Last4) : Payment(Amount);
Output
UpiPayment { Amount = 499, Vpa = meera@okbank }
CardPayment { Amount = 1299, Last4 = 4242 }

Each object in the JSON now starts with "$type":"upi" or "$type":"card", and deserialization uses it to create the right record. Add [JsonPolymorphic(TypeDiscriminatorPropertyName = "kind")] to the base type if your API partner expects another property name. Only list types you trust: never let the JSON choose an arbitrary .NET type, which is how older serializers became security holes.

Walkthrough: source-generated serialization

Normally the serializer uses reflection at run time to discover properties. That costs start-up time and does not work with Native AOT (compiling the whole app to native code ahead of time) or aggressive trimming. A source generator can write the serialization code at compile time instead.

Step 1: declare a context

C#
using System.Text.Json.Serialization;

[JsonSourceGenerationOptions(
    WriteIndented = true,
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    UseStringEnumConverter = true)]
[JsonSerializable(typeof(List<Ticket>))]
internal partial class TicketJsonContext : JsonSerializerContext
{
}

The class is partial because the generator writes the other half. [JsonSerializable] lists the root types you will serialize; types used inside them, such as Ticket and TravelClass, are included automatically.

Step 2: pass the generated type information

C#
List<Ticket> tickets = [new("PNR4521", "Rohtak", "Jaipur", TravelClass.Sleeper, "S4-21", 545m)];

string json = JsonSerializer.Serialize(tickets, TicketJsonContext.Default.ListTicket);
List<Ticket>? back = JsonSerializer.Deserialize(json, TicketJsonContext.Default.ListTicket);

Console.WriteLine(back?[0].Berth);
Output
S4-21

The generator creates a property for each type, named after it: List<Ticket> becomes ListTicket. Because you pass this type information, no reflection is needed.

Step 3: use it everywhere

In ASP.NET Core you plug the context into the app's JSON options so every endpoint uses it, and Native AOT templates do this for you. The ASP.NET Core course shows web APIs in detail.

Strict, safe reading of untrusted JSON

The default settings are relaxed: unknown properties are ignored, and if a property appears twice, the last value wins. An attacker can exploit that when one system checks the first value and another uses the last.

C#
using System.Text.Json;

string body = """{"pnr":"PNR4521","fare":545,"fare":5}""";

FareQuote? relaxed = JsonSerializer.Deserialize<FareQuote>(body, JsonSerializerOptions.Web);
Console.WriteLine($"Accepted by default: fare {relaxed?.Fare}");

try
{
    JsonSerializer.Deserialize<FareQuote>(body, JsonSerializerOptions.Strict);
}
catch (JsonException ex)
{
    Console.WriteLine($"Strict options: {ex.GetType().Name}");
}

record FareQuote(string Pnr, decimal Fare);
Output
Accepted by default: fare 5
Strict options: JsonException

Here Strict rejects the body twice over: the duplicate fare, and the lower-case names that do not match Pnr and Fare exactly. For an API that receives camelCase JSON, copy the strict settings into your own options object and add a camelCase naming policy.

For large files and network streams, use the async methods: await JsonSerializer.SerializeAsync(stream, value, options) and DeserializeAsync<T>. JsonSerializer.DeserializeAsyncEnumerable<T>(stream) reads a huge JSON array one item at a time, without holding the whole array in memory.

Common mistakes

  • Creating new JsonSerializerOptions() on every call. Cache it.
  • Expecting camelCase by default. Plain JsonSerializer.Serialize keeps PascalCase; use JsonSerializerDefaults.Web or a naming policy.
  • Missing property: on record attributes. Without it the attribute goes on the constructor parameter and may have no effect.
  • Deserializing a base type without [JsonDerivedType]. Derived data is lost or the abstract type cannot be created.
  • Trusting input blindly. Validate after deserializing, and consider strict options for public endpoints.

Key points

  • Use attributes (JsonPropertyName, JsonIgnore) for one type and JsonSerializerOptions for all types; reuse options objects.
  • JsonSerializerDefaults.Web, WhenWritingNull and JsonStringEnumConverter produce typical web JSON.
  • JsonNode edits JSON without a class; JsonDocument reads it quickly and must be disposed.
  • [JsonDerivedType] adds a $type discriminator so lists of different subtypes round-trip.
  • A JsonSerializerContext source generator removes reflection and supports Native AOT.
  • JsonSerializerOptions.Strict (.NET 10) rejects unknown and duplicate properties.

Exercise

A courier company in Lucknow sends parcel updates as JSON such as {"awb":"LKO7781","status":"in_transit","weightKg":1.25,"notes":null}. Create a Parcel record with properties AirwayBill, Status (an enum with Booked, InTransit, Delivered), WeightKg and Notes. Configure options so that awb maps to AirwayBill, the status string in_transit maps to the enum, names are camelCase, and nulls are not written. Deserialize the sample, change the status to Delivered, and serialize it again.

Show solution
C#
using System.Text.Json;
using System.Text.Json.Serialization;

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
options.Converters.Add(new JsonStringEnumConverter(JsonNamingPolicy.SnakeCaseLower));

string input = """{"awb":"LKO7781","status":"in_transit","weightKg":1.25,"notes":null}""";
Parcel parcel = JsonSerializer.Deserialize<Parcel>(input, options)!;

Parcel delivered = parcel with { Status = ParcelStatus.Delivered };
Console.WriteLine(JsonSerializer.Serialize(delivered, options));

enum ParcelStatus { Booked, InTransit, Delivered }

record Parcel(
    [property: JsonPropertyName("awb")] string AirwayBill,
    ParcelStatus Status,
    decimal WeightKg,
    string? Notes);
Output
{"awb":"LKO7781","status":"delivered","weightKg":1.25}

JsonNamingPolicy.SnakeCaseLower (available since .NET 8) turns InTransit into in_transit and back, so the enum converter understands the courier's format. The with expression copies the record with one change, and Notes disappears from the output because it is null.

Quick Quiz

1. Which setting makes System.Text.Json write camelCase names and read names case-insensitively?

2. Why is a JsonStringEnumConverter often preferred for enums?

3. What does [JsonDerivedType(typeof(UpiPayment), "upi")] on a base type do?

4. What is a main benefit of a JsonSerializerContext source generator?

Learn C# with a mentor

Free lessons get you started. Our Programming & Software Development training adds real projects and code reviews from working developers.

  • Project-based learning
  • Mentor code reviews
  • Free counselling call