CodeGym /Courses /C# SELF /Deep Dive into System.Text...

Deep Dive into System.Text.Json

C# SELF
Level 47 , Lesson 1
Available

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
[JsonIgnore]
Excludes the property from serialization/deserialization
[JsonPropertyName("name")]
Uses a different name in JSON
[JsonInclude]
Includes a non-public property/field in serialization
[JsonNumberHandling]
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
WriteIndented
true — pretty-print with indentation
PropertyNameCaseInsensitive
true — ignore property name case during deserialization
PropertyNamingPolicy
JsonNamingPolicy.CamelCase
DefaultIgnoreCondition
Rules for ignoring nulls/default values
ReferenceHandler
Preserve
,
IgnoreCycles
AllowTrailingCommas
true — allow trailing comma in arrays
NumberHandling
Convert numbers to strings/back (and more)
Converters
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.

2
Task
C# SELF, level 47, lesson 1
Locked
Object Serialization with JsonSerializerOptions
Object Serialization with JsonSerializerOptions
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION