System.Text.Json in Depth
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:
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);{
"pnr": "PNR4521",
"from": "Rohtak",
"to": "Jaipur",
"class": "Sleeper",
"fare": 545
}JsonSerializerDefaults.Webgives camelCase names and case-insensitive reading, the same defaults ASP.NET Core uses.[property: JsonPropertyName("pnr")]renames one property. Theproperty:target is needed on a record parameter so the attribute lands on the generated property.WhenWritingNullleaves outBerthbecause it isnull. Use[JsonIgnore]on a property to leave it out always, for example a password hash.JsonStringEnumConverterwrites"Sleeper"instead of0. 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.
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());Kochi: max 31
{"city":"Kochi","temp":{"min":24,"max":31},"alerts":["rain"],"checked":true}
rainThe ! 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:
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);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
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
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);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.
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);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.Serializekeeps PascalCase; useJsonSerializerDefaults.Webor 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 andJsonSerializerOptionsfor all types; reuse options objects. JsonSerializerDefaults.Web,WhenWritingNullandJsonStringEnumConverterproduce typical web JSON.JsonNodeedits JSON without a class;JsonDocumentreads it quickly and must be disposed.[JsonDerivedType]adds a$typediscriminator so lists of different subtypes round-trip.- A
JsonSerializerContextsource 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
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);{"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?
The web defaults set a camelCase naming policy and case-insensitive matching.
2. Why is a JsonStringEnumConverter often preferred for enums?
Numeric enum values change meaning when members move; names do not.
3. What does [JsonDerivedType(typeof(UpiPayment), "upi")] on a base type do?
The discriminator records which derived type each object is.
4. What is a main benefit of a JsonSerializerContext source generator?
The serialization code is generated at compile time instead of discovered by reflection.
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
