1. Introduction
In this lesson we'll move from "just save me the list" to flexible, precise and high-performance serialization using System.Text.Json. In modern .NET projects JSON is the de-facto standard for data exchange. Basic calls to Serialize/Deserialize are simple, but real tasks need fine tuning: ignoring/renaming fields, controlling date formats, protecting against cycles, working with large data volumes, custom converters, etc.
We'll cover not only the JsonSerializer methods, but also settings via JsonSerializerOptions, attributes, working with Stream, memory management and injecting your own serialization rules via JsonConverter.
Short history and positioning of System.Text.Json
For a long time Newtonsoft.Json (Json.NET) dominated in .NET — flexible and mature, but not always the fastest or lightest in dependencies. Since .NET Core 3.0 the built-in System.Text.Json appeared: high performance, minimal dependencies (part of the platform), tight integration with ASP.NET Core and continuous improvements with .NET releases.
2. Core classes and methods
The main player is the static class JsonSerializer, which gives two directions:
- Serialization: object → JSON string (Serialize)
- Deserialization: JSON string → object of the needed type (Deserialize)
Example: serializing a simple object
using System.Text.Json;
var person = new Person { Name = "Ivan", Age = 30 };
string jsonString = JsonSerializer.Serialize(person);
Console.WriteLine(jsonString); // {"Name":"Ivan","Age":30}
Example: deserialization
var json = "{\"Name\":\"Anna\",\"Age\":22}";
var anna = JsonSerializer.Deserialize<Person>(json);
Console.WriteLine(anna.Name); // Anna
Note: the Person type was already implemented in previous lectures — we use it here too.
3. Controlling serialization: JsonSerializerOptions
In real projects you almost always need settings: property names in camelCase, date formats, cycle handling, default value treatment, etc. All of this is controlled via JsonSerializerOptions.
Example configuration
var options = new JsonSerializerOptions
{
WriteIndented = true, // Pretty-print JSON (adds spaces and line breaks)
PropertyNameCaseInsensitive = true, // Ignore case of property names during deserialization
PropertyNamingPolicy = JsonNamingPolicy.CamelCase // camelCase for properties (instead of PascalCase)
};
string json = JsonSerializer.Serialize(person, options);
/*
{
"name": "Ivan",
"age": 30
}
*/
Why is this important? Most frontend frameworks expect camelCase, not .NET's PascalCase.
4. Attributes: System.Text.Json.Serialization
Sometimes it's more convenient to control serialization right in the model with attributes. You add them to fields/properties to influence names, inclusion/exclusion and value handling.
Main attributes
| Attribute | What it does |
|---|---|
|
Excludes the property from serialization/deserialization |
|
Uses a different name in JSON |
|
Includes a non-public property/field in serialization |
|
Controls handling of numeric values |
Example: controlling properties via attributes
using System.Text.Json.Serialization;
public class Person
{
[JsonPropertyName("full_name")]
public string Name { get; set; }
[JsonIgnore]
public int SecretCode { get; set; }
public int Age { get; set; }
}
var person = new Person { Name = "Pyotr", Age = 45, SecretCode = 123 };
string json = JsonSerializer.Serialize(person);
// {"full_name":"Pyotr","Age":45}
Note: SecretCode did not get into JSON, and Name was serialized as "full_name".
5. Serializing collections and nested objects
Collections — it's simple
var numbers = new List<int> { 1, 2, 3 };
string json = JsonSerializer.Serialize(numbers); // [1,2,3]
var people = new List<Person> {
new Person { Name = "Anna", Age = 20 },
new Person { Name = "Maxim", Age = 40 }
};
string jsonList = JsonSerializer.Serialize(people);
// [{"Name":"Anna","Age":20},{"Name":"Maxim","Age":40}]
Nested structures
public class Group
{
public string Name { get; set; }
public List<Person> Members { get; set; }
}
var group = new Group
{
Name = "Developers",
Members = new List<Person>
{
new Person { Name = "Sasha", Age = 23 },
new Person { Name = "Masha", Age = 28 }
}
};
string jsonGroup = JsonSerializer.Serialize(group, options);
/*
{
"name": "Developers",
"members": [
{ "name": "Sasha", "age": 23 },
{ "name": "Masha", "age": 28 }
]
}
*/
6. Deserialization: what matters?
var json = "[{\"Name\":\"Ivan\",\"Age\":21}]";
var list = JsonSerializer.Deserialize<List<Person>>(json);
Console.WriteLine(list[0].Name); // Ivan
A common scenario: if a field is missing in JSON, the corresponding property gets the default value. Extra fields in JSON that don't exist in the model are ignored. But if types don't match (for example, a string arrives where a number is expected) — deserialization will throw an exception.
7. Handling dates, times, formats and numeric values
public class Meeting
{
public string Topic { get; set; }
public DateTime Time { get; set; }
}
var meeting = new Meeting { Topic = "Meeting", Time = DateTime.Now };
string json = JsonSerializer.Serialize(meeting);
// {"Topic":"Meeting","Time":"2024-06-06T20:30:00.0000000+03:00"}
By default DateTime is serialized in ISO 8601. Need another form (for example, date only)? Use a separate property or a custom converter (see below).
FAQ: To serialize numbers as strings (for example, phone numbers or big IDs), use the attribute [JsonNumberHandling(JsonNumberHandling.WriteAsString)].
8. Streams and working with files
You can work not only with strings, but also with Stream — this is important for big data (files, network).
Example: writing to a file
using var fs = File.Create("person.json");
JsonSerializer.Serialize(fs, person);
// Don't forget to call fs.Flush() or use using!
Example: reading from a file
using var fs = File.OpenRead("person.json");
var restored = JsonSerializer.Deserialize<Person>(fs);
With streams you also have async methods SerializeAsync/DeserializeAsync — useful for high-load services.
9. Custom converters
If the built-in rules don't fit (non-standard date/number formats, complex values, custom structures) — write a JsonConverter.
Example: date only as "dd.MM.yyyy"
public class CustomDateConverter : JsonConverter<DateTime>
{
public override DateTime Read(
ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
return DateTime.ParseExact(reader.GetString(), "dd.MM.yyyy", null);
}
public override void Write(
Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString("dd.MM.yyyy"));
}
}
var options = new JsonSerializerOptions();
options.Converters.Add(new CustomDateConverter());
var dt = new DateTime(2024, 6, 1);
string json = JsonSerializer.Serialize(dt, options); // "01.06.2024"
Custom converters are handy for serializing coordinates, vectors, colors, non-standard dates and currencies, various ID formats, and more.
10. Useful nuances
Handling cyclic references and deep hierarchies
var options = new JsonSerializerOptions
{
ReferenceHandler = ReferenceHandler.Preserve, // Preserves objects with $id/$ref
WriteIndented = true
};
Important: JSON will contain special properties $id and $ref. For exchange with external systems that don't understand them, this may not be suitable.
Differences between System.Text.Json and Newtonsoft.Json
System.Text.Json is already very powerful, but doesn't yet cover every scenario of Newtonsoft.Json (private constructors, complex dynamic objects, etc.). For most standard tasks we recommend the built-in serializer — it's faster and avoids extra dependencies.
Interactive JSON work: the DOM API
When you need to "walk" through JSON without a full model, use JsonDocument and JsonElement.
using var doc = JsonDocument.Parse(jsonString);
JsonElement root = doc.RootElement;
if (root.TryGetProperty("Name", out var nameProperty))
{
Console.WriteLine(nameProperty.GetString());
}
11. Handy options and their effects
| Property | Value/Purpose |
|---|---|
|
true — pretty-print with indentation |
|
true — ignore property name case during deserialization |
|
|
|
Rules for ignoring nulls/default values |
|
, |
|
true — allow trailing comma in arrays |
|
Convert numbers to strings/back (and more) |
|
List of custom converters |
12. Common mistakes and practical tips
Mistake #1: wrong type during deserialization. If you serialized a list, deserialize into a list: List<T>, not a single object.
Mistake #2: wrong property name casing. Without case-insensitive settings properties might "not be found". Use PropertyNameCaseInsensitive or set PropertyNamingPolicy.
Mistake #3: incorrect date handling. Default format is ISO 8601. Need something else — create and apply a converter (JsonConverter<DateTime>).
Mistake #4: expecting private/static fields to be serialized. By default public properties are used. For non-standard cases use the appropriate attributes (for example, [JsonInclude]).
Mistake #5: misunderstanding default values. Missing field in JSON → default value for the property. Account for this in your logic.
Mistake #6: improper stream handling. Close resources with using or await using to avoid leaks and locks.
GO TO FULL VERSION