Day 25: JSON Serialization - Teaching Your Objects to Speak the Universal Language
arrow_back All Posts
April 06, 2026 12 min read .NET/C#

Day 25: JSON Serialization - Teaching Your Objects to Speak the Universal Language

Yesterday you learned how to read and write files — saving text, streaming bytes, and making your data survive a power outage. But here's the thing: when your program talks to another program — a web API, a mobile app, a JavaScript frontend — nobody wants to receive a raw .txt dump. They want structure. They want something both humans and machines can read. They want JSON.

1. What Is JSON?

JSON stands for JavaScript Object Notation, which is a bit misleading because it has almost nothing to do with JavaScript anymore. It's the universal language of data exchange on the internet — the Esperanto that actually caught on.

JSON is dead simple. It's built on two structures: objects (curly braces with key-value pairs) and arrays (square brackets with ordered values):

// This is what JSON looks like — not C# code, just raw JSON:
// {
//   "name": "Farhad",
//   "age": 30,
//   "isAwake": true,
//   "hobbies": ["coding", "coffee", "pretending to exercise"],
//   "address": {
//     "city": "Helsinki",
//     "country": "Finland"
//   }
// }

That's it. Strings in double quotes, numbers without quotes, booleans as true/false, arrays in brackets, nested objects in braces, and null for "I got nothing." Every REST API you'll ever call speaks this language. Every config file you'll ever read is probably JSON (or YAML pretending to be better than JSON — it's not).

The question is: how do you turn your nice C# objects into JSON strings, and how do you turn JSON strings back into C# objects? That process has a name — serialization and deserialization.

2. System.Text.Json — The Built-In JSON Library

.NET has a built-in JSON library called System.Text.Json, and it lives in the System.Text.Json namespace. No NuGet packages to install. No third-party dependencies. It's just there, ready to go — like a fire extinguisher you didn't know was bolted to the wall.

using System.Text.Json;

Console.WriteLine("System.Text.Json is ready to go!");

You might hear people mention Newtonsoft.Json (also called Json.NET) — that was the go-to JSON library for over a decade before Microsoft built their own. Newtonsoft is still widely used and has more features, but System.Text.Json is faster, allocates less memory, and ships with .NET. For everything we're doing today, the built-in library is more than enough.

The star of the show is the JsonSerializer class. It has two main jobs:

  • Serialize — turn a C# object into a JSON string
  • Deserialize — turn a JSON string back into a C# object

Let's see them in action.

3. Serialize - Object to JSON String

Serialization is the process of converting a C# object into a JSON string. Think of it like packing your belongings into a standardized shipping box — everything gets flattened into text that can travel over a network, be saved to a file, or be read by any other language.

using System.Text.Json;

var person = new Person("Farhad", 30, "Helsinki");

string json = JsonSerializer.Serialize(person);
Console.WriteLine(json);
// Output: {"Name":"Farhad","Age":30,"City":"Helsinki"}

class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
    public string City { get; set; }

    public Person(string name, int age, string city)
    {
        Name = name;
        Age = age;
        City = city;
    }
}

One line. JsonSerializer.Serialize(person) takes your object, inspects its public properties, and builds a JSON string. Private fields? Ignored. Methods? Ignored. It only cares about public properties with getters.

The output is compact — no spaces, no line breaks, just raw JSON packed tight. That's great for sending over a network where every byte counts. But it's ugly to read. We'll fix that in a minute.

You can also serialize anonymous types and collections just as easily:

// Anonymous type
var config = new { Theme = "dark", FontSize = 14 };
Console.WriteLine(JsonSerializer.Serialize(config));
// {"Theme":"dark","FontSize":14}

// List of strings
List<string> tags = ["csharp", "dotnet", "json"];
Console.WriteLine(JsonSerializer.Serialize(tags));
// ["csharp","dotnet","json"]

4. Deserialize - JSON String to Object

Deserialization is the reverse — taking a JSON string and inflating it back into a real C# object. You're unpacking that shipping box and putting everything back on the shelves.

using System.Text.Json;

string json = """{"Name":"Farhad","Age":30,"City":"Helsinki"}""";

Person? person = JsonSerializer.Deserialize<Person>(json);

Console.WriteLine($"{person?.Name} is {person?.Age} years old, living in {person?.City}");
// Farhad is 30 years old, living in Helsinki

Notice the <Person> in angle brackets — you're telling the deserializer what type to build. It reads the JSON, matches the keys to property names, and constructs the object. The return type is nullable (Person?) because deserialization can return null if the JSON string is literally "null".

Here's the catch: the JSON keys must match your property names (by default, it's case-insensitive for reading). If the JSON says "name" but your property is Name, it'll still work. But if the JSON says "fullName" and your property is Name? That property gets its default value — null for strings, 0 for ints. No error, no warning. Just silence.

// JSON has "fullName" but the class has "Name" — mismatch!
string json = """{"fullName":"Farhad","Age":30}""";
Person? person = JsonSerializer.Deserialize<Person>(json);
Console.WriteLine(person?.Name); // null — the key didn't match
Console.WriteLine(person?.Age);  // 30 — this one matched

5. JsonSerializerOptions — Making JSON Behave

The default behavior is fine for quick tests, but real-world apps need more control. That's where JsonSerializerOptions comes in — a settings object that lets you customize how serialization works.

using System.Text.Json;

var options = new JsonSerializerOptions
{
    WriteIndented = true,                                    // Pretty-print with indentation
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,       // PascalCase → camelCase
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull // Skip null properties
};

var person = new Person("Farhad", 30, null);
string json = JsonSerializer.Serialize(person, options);
Console.WriteLine(json);

Output:

{
  "name": "Farhad",
  "age": 30
}

Let's break down the three big options:

WriteIndented = true — Adds line breaks and indentation so humans can actually read the output. Use this for logging, debugging, and config files. Leave it false (the default) when sending data over HTTP — bandwidth matters.

PropertyNamingPolicy = JsonNamingPolicy.CamelCase — Converts your PascalCase C# property names (FirstName) to camelCase JSON keys (firstName). This is the standard in JavaScript and most REST APIs. Without this, your JSON keys will be PascalCase, and every JavaScript developer who consumes your API will give you the stink eye.

DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull — Skips any property that's null instead of writing "city": null. Keeps your JSON clean and smaller.

Pro tip: don't create a new JsonSerializerOptions every time you serialize. The object caches metadata about your types internally, so reusing the same instance is significantly faster:

// Do this — create once, reuse everywhere
static readonly JsonSerializerOptions s_options = new()
{
    WriteIndented = true,
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

// Then use it
string json = JsonSerializer.Serialize(person, s_options);

6. Attributes - Fine-Grained Control Per Property

Sometimes you need to control how individual properties get serialized — not the whole object. That's where JSON attributes come in. You decorate your properties with these, and the serializer obeys.

[JsonPropertyName] — Overrides the JSON key name for a specific property:

using System.Text.Json.Serialization;

class User
{
    [JsonPropertyName("user_name")]
    public string Username { get; set; } = "";

    [JsonPropertyName("e_mail")]
    public string Email { get; set; } = "";

    public int Age { get; set; }
}

var user = new User { Username = "farhad", Email = "farhad@example.com", Age = 30 };
Console.WriteLine(JsonSerializer.Serialize(user));
// {"user_name":"farhad","e_mail":"farhad@example.com","Age":30}

This is handy when the API you're talking to uses snake_case or some other naming convention that doesn't match your C# style.

[JsonIgnore] — Completely excludes a property from serialization and deserialization:

class User
{
    public string Username { get; set; } = "";

    [JsonIgnore]
    public string PasswordHash { get; set; } = "";

    public string Email { get; set; } = "";
}

var user = new User { Username = "farhad", PasswordHash = "abc123hashed", Email = "f@test.com" };
Console.WriteLine(JsonSerializer.Serialize(user));
// {"Username":"farhad","Email":"f@test.com"}
// No PasswordHash — exactly what we want!

You'd never want to accidentally send a password hash over the wire. [JsonIgnore] makes sure that can't happen.

[JsonInclude] — The opposite direction. By default, the serializer only sees public properties. If you have a public field (not a property) that you want included, slap [JsonInclude] on it:

class Config
{
    [JsonInclude]
    public string environment = "production";  // This is a field, not a property

    public int MaxRetries { get; set; } = 3;
}

In practice, you'll use [JsonPropertyName] and [JsonIgnore] constantly. [JsonInclude] is more of a niche tool for when your class design doesn't follow the "everything is a property" convention.

7. Working With Records and Collections

Records — those immutable data types we covered on Day 20 — are a natural fit for JSON serialization. They're designed to hold data, and JSON is designed to represent data. It's a match made in heaven:

using System.Text.Json;

var options = new JsonSerializerOptions
{
    WriteIndented = true,
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

// Records serialize beautifully
record Product(string Name, decimal Price, string[] Tags);

var product = new Product("Mechanical Keyboard", 149.99m, ["peripherals", "gaming"]);
string json = JsonSerializer.Serialize(product, options);
Console.WriteLine(json);
// {
//   "name": "Mechanical Keyboard",
//   "price": 149.99,
//   "tags": ["peripherals", "gaming"]
// }

// And deserialize right back
Product? restored = JsonSerializer.Deserialize<Product>(json, options);
Console.WriteLine(restored);
// Product { Name = Mechanical Keyboard, Price = 149.99, Tags = System.String[] }

Records work out of the box because System.Text.Json can use the constructor parameters to match JSON properties. It matches by name (case-insensitive), so "name" in JSON maps to the Name parameter in the constructor. Clean.

Collections serialize and deserialize just as smoothly:

// Lists
List<Product> catalog =
[
    new("Keyboard", 149.99m, ["peripherals"]),
    new("Mouse", 59.99m, ["peripherals", "wireless"]),
    new("Monitor", 399.99m, ["displays"])
];

string catalogJson = JsonSerializer.Serialize(catalog, options);
Console.WriteLine(catalogJson);
// A JSON array of three product objects

// Deserialize back to a List
List<Product>? restored = JsonSerializer.Deserialize<List<Product>>(catalogJson, options);
Console.WriteLine($"Got {restored?.Count} products back"); // Got 3 products back

// Dictionaries become JSON objects
Dictionary<string, int> scores = new()
{
    ["Alice"] = 95,
    ["Bob"] = 87,
    ["Charlie"] = 92
};

Console.WriteLine(JsonSerializer.Serialize(scores, options));
// {
//   "Alice": 95,
//   "Bob": 87,
//   "Charlie": 92
// }

Dictionaries with string keys become JSON objects naturally — the keys become JSON property names, the values become JSON values. It just works.

8. Common Gotchas

JSON serialization in .NET is smooth until it isn't. Here are the traps that'll eat your afternoon if you don't know about them:

Case sensitivity on deserialization. By default, System.Text.Json is case-insensitive when deserializing — "name" matches Name. But if you set PropertyNameCaseInsensitive = false (or use some older configurations), it becomes case-sensitive, and "name" won't match Name. If properties are mysteriously null after deserialization, check the casing first.

// This works by default (case-insensitive)
string json = """{"name":"Farhad"}""";
Person? p = JsonSerializer.Deserialize<Person>(json);
Console.WriteLine(p?.Name); // "Farhad" — matched despite lowercase key

Missing properties don't throw errors. If the JSON is missing a property your class expects, that property just gets its default value — null, 0, false, whatever. No exception. No warning. This is by design, but it can lead to subtle bugs if you're expecting every field to be present.

DateTimeOffset formatting. System.Text.Json uses ISO 8601 format for dates: "2025-01-15T14:30:00+02:00". If the API you're consuming sends dates in some other format — Unix timestamps, "MM/dd/yyyy", or whatever chaos they've chosen — deserialization will throw a JsonException. You'll need a custom converter for those cases (which is a topic for another day).

// This format works out of the box
string json = """{"CreatedAt":"2025-01-15T14:30:00+02:00"}""";

// This will throw — it's not ISO 8601
string badJson = """{"CreatedAt":"01/15/2025 2:30 PM"}""";
// JsonException: The JSON value could not be converted to DateTimeOffset

Circular references. If object A has a reference to object B, and object B has a reference back to object A, the serializer will throw a JsonException about cycles. You can handle this with ReferenceHandler.Preserve in your options, but it adds $id and $ref metadata that makes the JSON ugly. Best fix: don't serialize circular object graphs — use DTOs (Data Transfer Objects) that flatten the structure.

Enums serialize as numbers by default. If you have an enum Status.Active, it serializes as 0, not "Active". To get the string name, add a converter:

var options = new JsonSerializerOptions
{
    Converters = { new JsonStringEnumConverter() }
};

Console.WriteLine(JsonSerializer.Serialize(Status.Active, options));
// "Active" — not 0

9. Your Homework: Build a JSON Contacts App

Time to put it all together. Build a console app that manages a contact list — adding contacts and saving them to a JSON file, then loading them back when the app starts. Think of it as a tiny address book that remembers everything between runs.

Here's your starter template:

using System.Text.Json;

const string FilePath = "contacts.json";

var options = new JsonSerializerOptions
{
    WriteIndented = true,
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};

// Load existing contacts from file (or start fresh)
List<Contact> contacts = LoadContacts();

Console.WriteLine("=== Contact Manager ===");
Console.WriteLine($"Loaded {contacts.Count} contact(s) from file.\n");

bool running = true;
while (running)
{
    Console.WriteLine("1. Add contact");
    Console.WriteLine("2. List contacts");
    Console.WriteLine("3. Save and quit");
    Console.Write("\nChoice: ");

    switch (Console.ReadLine())
    {
        case "1":
            Console.Write("Name: ");
            string name = Console.ReadLine() ?? "";
            Console.Write("Email: ");
            string email = Console.ReadLine() ?? "";
            Console.Write("Phone: ");
            string phone = Console.ReadLine() ?? "";

            contacts.Add(new Contact(name, email, phone, DateTimeOffset.Now));
            Console.WriteLine($"Added {name}!\n");
            break;

        case "2":
            if (contacts.Count == 0)
            {
                Console.WriteLine("No contacts yet.\n");
            }
            else
            {
                foreach (var c in contacts)
                {
                    Console.WriteLine($"  {c.Name} | {c.Email} | {c.Phone} | Added: {c.AddedOn:yyyy-MM-dd}");
                }
                Console.WriteLine();
            }
            break;

        case "3":
            SaveContacts(contacts);
            Console.WriteLine($"Saved {contacts.Count} contact(s). Goodbye!");
            running = false;
            break;

        default:
            Console.WriteLine("Invalid choice.\n");
            break;
    }
}

List<Contact> LoadContacts()
{
    if (!File.Exists(FilePath))
        return [];

    string json = File.ReadAllText(FilePath);
    return JsonSerializer.Deserialize<List<Contact>>(json, options) ?? [];
}

void SaveContacts(List<Contact> contactList)
{
    string json = JsonSerializer.Serialize(contactList, options);
    File.WriteAllText(FilePath, json);
}

record Contact(
    string Name,
    string Email,
    string Phone,
    DateTimeOffset AddedOn
);

Challenges to try on your own:

  • Add a "search by name" option that filters contacts using LINQ
  • Add a "delete contact" option that removes a contact by index
  • Add a [JsonIgnore] property to Contact — maybe a Notes field you don't want saved

Run it, add a few contacts, quit, then run it again. Your contacts should still be there — loaded straight from the JSON file you saved yesterday. That's persistence — your data outlives your program.

Summary of Day 25

  • JSON is the standard data format for APIs and config files — objects in curly braces, arrays in square brackets, everything in key-value pairs.
  • System.Text.Json is .NET's built-in JSON library — no packages needed. The main class is JsonSerializer.
  • Serialize() converts a C# object to a JSON string. Deserialize<T>() converts a JSON string back to a C# object.
  • JsonSerializerOptions controls formatting: WriteIndented for readability, CamelCase naming for API compatibility, and WhenWritingNull to skip null values.
  • Attributes give you per-property control: [JsonPropertyName] renames keys, [JsonIgnore] hides properties, [JsonInclude] exposes fields.
  • Records and collections serialize naturally — records match constructor parameters by name, lists become JSON arrays, dictionaries become JSON objects.
  • Watch out for case mismatches, missing properties silently defaulting, non-ISO date formats, and enums serializing as numbers.

Tomorrow: we'll talk about HTTP and REST APIs — calling real APIs over the internet with HttpClient. You've been reading and writing files locally, and now you know how to speak JSON. Time to take that knowledge online and actually talk to servers out in the wild. Your console app is about to become a web citizen. 🚀

See you on Day 26!

Share
FM

Farhad Mammadov

.NET Engineer & Cloud Architect · Bayern, Germany. Writing about scalable backend systems, AWS, and SRE.