Lesson 30 • Advanced Track
JSON Processing with System.Text.Json
By the end of this lesson you'll be able to turn C# objects into JSON and back again with .NET's built-in System.Text.Json — controlling the output format, mapping awkward API key names onto tidy C# properties, and reading JSON you don't even have a class for. This is the skill behind every API call, config file, and saved document your apps will touch.
What You'll Learn
- Serialize C# objects to JSON with JsonSerializer.Serialize
- Deserialize JSON back into typed objects with Deserialize<T>
- Format output with JsonSerializerOptions (WriteIndented, camelCase)
- Match mismatched casing with PropertyNameCaseInsensitive
- Map awkward keys with [JsonPropertyName] and hide fields with [JsonIgnore]
- Read schema-less JSON with JsonDocument and edit it with JsonNode
System.Text.Json can serialize straight to a stream — the same streams you met in that lesson.💡 Real-World Analogy
JSON serialization is like flat-pack furniture. Your C# object is an assembled chair — fine to use in your living room (your program), but impossible to post through a letterbox. Serializing flattens it into a labelled, boxed kit (a JSON string) that travels easily across a network or onto disk. Deserializing is the person at the other end following the labels to rebuild the exact same chair. System.Text.Json is the factory that packs and unpacks — and because it's built into .NET, there's nothing extra to install and it's fast enough for high-traffic servers.
The JSON Toolbox at a Glance
JSON (JavaScript Object Notation) is a plain-text format for structured data: objects in {curly braces}, arrays in [square brackets], plus strings, numbers, true/false and null. It's the universal language of web APIs. System.Text.Json (often shortened to STJ) is the modern, high-performance JSON library that ships inside .NET — no NuGet package required.
It gives you a few tools for different jobs. Pick by how much structure you know up front:
| Tool | Best for | Read / Write |
|---|---|---|
| JsonSerializer | Known shapes — DTOs, API models, config | Both |
| JsonDocument | Peeking at unknown JSON, read-only, fast | Read only |
| JsonNode | Editing dynamic JSON without a class | Both |
| Utf8JsonWriter | Building JSON by hand at top speed | Write only |
Ninety percent of the time you'll reach for JsonSerializer — so that's where we start, and where you'll spend most of this lesson.
1. Serializing — Object → JSON
Serializing means turning a live C# object into a JSON string you can send or save. The one call you need is JsonSerializer.Serialize(obj). By default it reads every public property and produces compact, single-line JSON with PascalCase keys. To shape the output you pass a JsonSerializerOptions: WriteIndented = true for readable formatting, and PropertyNamingPolicy = JsonNamingPolicy.CamelCase to emit the camelCase keys that most web APIs expect. Read this worked example, run it, then you'll serialize one yourself.
Worked example: serialize an object
Read every comment, run it, and compare compact vs indented output.
using System;
using System.Text.Json;
// A plain class (a DTO — "data transfer object") that mirrors your JSON shape.
class Book
{
public string Title { get; set; } = "";
public string Author { get; set; } = "";
public int Year { get; set; }
public bool InStock { get; set; }
}
class Program
{
static void Main()
{
// 1) Build a normal C# object.
var book = new Book
{
Title = "The Pragmatic Programmer",
Author = "Hunt & Thom
...Your turn. The program below is almost complete — fill in the two blanks marked ___ using the hints in the comments, then run it and check the indented output.
🎯 Your turn: serialize a Movie
Turn on indenting and call the Serialize method, then check the output.
using System;
using System.Text.Json;
class Movie
{
public string Title { get; set; } = "";
public int Year { get; set; }
}
class Program
{
static void Main()
{
// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
// 1) Build a Movie object.
var movie = new Movie { Title = "Inception", Year = 2010 };
// 2) Make options that pretty-print the JSON.
var options = new JsonSerializerOptions
{
WriteIndented = _
...2. Deserializing — JSON → Object
Deserializing is the reverse: JsonSerializer.Deserialize<T>(json) reads a JSON string and rebuilds a typed C# object. You name the target type in the angle brackets — Deserialize<Weather>(json) — and STJ creates the object and copies each matching value into a property. Two things bite beginners here. First, the result is nullable (Weather?), because the JSON could literally be null. Second, STJ is case-sensitive by default: a JSON key "city" will not fill a C# property City unless you set PropertyNameCaseInsensitive = true. Watch both in the worked example.
Worked example: deserialize into an object
See case-insensitive matching and safe null handling in action.
using System;
using System.Text.Json;
class Weather
{
// System.Text.Json needs a PUBLIC parameterless constructor (the
// default one you get for free) so it can build the object, then
// fill these settable properties one by one.
public string City { get; set; } = "";
public double TempC { get; set; }
public bool IsRaining { get; set; }
}
class Program
{
static void Main()
{
// The JSON you might receive from a weather API.
string json = "{\"ci
...Now you try. Deserialize the JSON into a User, then read a property off the object you built. Fill in the two ___ blanks:
🎯 Your turn: deserialize then read a property
Name the type in the angle brackets, then print the Name property.
using System;
using System.Text.Json;
class User
{
public string Name { get; set; } = "";
public int Age { get; set; }
}
class Program
{
static void Main()
{
// 🎯 YOUR TURN — replace each ___ then press "Try it Yourself".
string json = "{\"name\":\"Ada\",\"age\":36}";
// 1) Deserialize the JSON into a User.
// The type goes in the angle brackets: Deserialize<User>(...)
User? user = JsonSerializer.Deserialize<___>(json); // 👉 the
...3. Mapping Names with Attributes
Real APIs rarely hand you keys that match your C# property names. A field might arrive as "first_name" — which isn't even a legal C# identifier. Decorate the property with [JsonPropertyName("first_name")] and STJ maps that exact key to your tidy FirstName property, both when reading and writing. Use [JsonIgnore] on anything you never want in the JSON — a password, a session token, or a value you compute on the fly. These attributes live in the System.Text.Json.Serialization namespace, so remember the second using.
Worked example: [JsonPropertyName] & [JsonIgnore]
Map snake_case keys to PascalCase properties and hide a secret field.
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
// Real APIs love snake_case keys ("first_name") that aren't valid C#
// identifiers. [JsonPropertyName] maps the exact JSON key to a tidy
// C# property name — both directions, serialize AND deserialize.
class Account
{
[JsonPropertyName("first_name")]
public string FirstName { get; set; } = "";
[JsonPropertyName("last_name")]
public string LastName { get; set; } = "";
// [JsonIgnore] leaves this
...🔎 Deep Dive: the JsonSerializerOptions object
Nearly every knob you'll ever turn lives on one object. Build it once and reuse it — STJ caches type metadata against the options instance, so creating a fresh one on every call quietly throws that cache away and slows you down.
var options = new JsonSerializerOptions
{
WriteIndented = true, // pretty, multi-line output
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, // Title -> "title"
PropertyNameCaseInsensitive = true, // match keys ignoring case
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull // skip nulls
};
// Reuse it for every call:
string json = JsonSerializer.Serialize(obj, options);
var back = JsonSerializer.Deserialize<MyType>(json, options);A naming policy (CamelCase) changes all properties at once; a [JsonPropertyName] attribute overrides the policy for one specific property. Attribute wins where the two disagree.
4. JSON Without a Class — JsonDocument & JsonNode
Sometimes you don't have a model — you just need one value out of a big response, or you want to tweak a config file you didn't design. JsonDocument parses JSON into a read-only tree of JsonElements with very few allocations; wrap it in using so its pooled memory is returned. JsonNode is the read-write cousin: parse it, then change values or add keys with simple indexer syntax (node["stars"] = 9999;) before turning it back into a string. Reach for these when the shape is unknown or you only care about a slice of it.
Worked example: JsonDocument & JsonNode
Read a few values with JsonDocument, then edit a tree with JsonNode.
using System;
using System.Text.Json;
using System.Text.Json.Nodes;
class Program
{
static void Main()
{
string json = """
{
"name": "Repo",
"stars": 1280,
"tags": ["csharp", "json", "dotnet"]
}
""";
// === JsonDocument — read-only, very low allocation ===
// Great when you only need to PEEK at a few values and won't
// change anything. 'using' disposes its pooled memory afterwards.
usin
...🔎 System.Text.Json vs Newtonsoft.Json
For years the default JSON library in .NET was Newtonsoft.Json (the Newtonsoft.Json NuGet package, also called Json.NET). You'll still meet it everywhere in existing code, and it's superb — extremely flexible, with features STJ doesn't have.
System.Text.Json is Microsoft's newer, built-in replacement (since .NET Core 3.0). It needs no package, is noticeably faster, uses less memory, and works with ahead-of-time (AOT) compilation. The trade-offs to remember:
- • STJ is case-sensitive by default; Newtonsoft is case-insensitive by default. This is the #1 surprise when porting code.
- • Newtonsoft serializes private members and uses non-public constructors more readily; STJ sticks to public properties and a public parameterless constructor unless you opt in.
- • Some advanced Newtonsoft features (e.g.
JsonConvertsettings, certain attributes,LINQ to JSONniceties) have different or no direct equivalents in STJ.
Rule of thumb: choose System.Text.Json for new projects for the speed and zero dependencies; keep Newtonsoft when you need a feature STJ lacks or you're maintaining code already built on it. The API names rhyme (JsonSerializer.Serialize vs JsonConvert.SerializeObject), so the mental model transfers.
Pro Tips
- 💡 Create your
JsonSerializerOptionsonce and reuse it. STJ caches metadata on the instance; a new options object per call silently kills that cache. - 💡 Dispose
JsonDocumentwith ausing— it rents memory from a pool and leaks it if you don't. - 💡 Match the API's casing once with a naming policy rather than tagging every property; reserve
[JsonPropertyName]for the odd key that breaks the pattern. - 💡 Use
JsonSerializer.Serialize(stream, obj)to write straight to a file or response stream — no giant intermediate string in memory. - 💡 In .NET 8+, source generators (
[JsonSerializable]+JsonSerializerContext) give reflection-free, AOT-friendly serialization — ideal for trimmed or Native AOT apps.
Common Errors (and the fix)
- All my properties are empty / 0 after deserializing: a casing mismatch. The JSON key
"city"won't fillCityby default. SetPropertyNameCaseInsensitive = true, or apply a camelCase naming policy, or use[JsonPropertyName("city")]. - "JsonException: The JSON value could not be converted to … no parameterless constructor": STJ builds your object with the public, parameterless constructor and then sets properties. Add an empty constructor (or remove the one that takes arguments), or use
[JsonConstructor]to point STJ at the right one. - Deserialize returns
nullwith no error: the input was literally the textnull, or didn't match your type at all. Always treat the result as nullable (MyType?) and check before using it. - "System.Text.Json.JsonException: '<char>' is an invalid start of a value": the string isn't valid JSON — a trailing comma, a single quote instead of double, or HTML/an error page where you expected JSON. Print the raw input and validate it.
- "NullReferenceException" right after deserializing a collection: deserializing missing JSON gives you a
nulllist, not an empty one. Guard with?? new List<T>()before you loop.
📋 Quick Reference
| Task | Code | Notes |
|---|---|---|
| Object → JSON | JsonSerializer.Serialize(obj) | Compact by default |
| JSON → object | JsonSerializer.Deserialize<T>(json) | Result is T? |
| Pretty-print | WriteIndented = true | On the options |
| camelCase keys | PropertyNamingPolicy = JsonNamingPolicy.CamelCase | All properties |
| Ignore casing | PropertyNameCaseInsensitive = true | When reading |
| Rename one key | [JsonPropertyName("k")] | Per property |
| Skip a property | [JsonIgnore] | Both directions |
| Peek at unknown JSON | JsonDocument.Parse(json) | Read-only, dispose it |
| Edit dynamic JSON | JsonNode.Parse(json) | Read & write |
Frequently Asked Questions
Q: My object deserialized but every field is empty — why?
Almost always a casing mismatch. System.Text.Json is case-sensitive by default, so JSON key "name" doesn't fill property Name. Set PropertyNameCaseInsensitive = true in your options, or use a camelCase naming policy, or tag the property with [JsonPropertyName].
Q: Do I have to create a class to read JSON?
No. Use JsonDocument to read values out of arbitrary JSON, or JsonNode if you also want to edit it. A class is just the most convenient option when you know the shape ahead of time.
Q: Should I use System.Text.Json or Newtonsoft.Json?
For new code, prefer System.Text.Json — it's built in, faster, and AOT-friendly. Stay with Newtonsoft if you need a feature it lacks or you're maintaining a codebase already built on it. Remember STJ is case-sensitive by default and Newtonsoft isn't.
Q: Why does STJ ignore my fields and private properties?
By default it only serializes public properties. To include public fields, set IncludeFields = true in the options (or add [JsonInclude]). It also needs a public parameterless constructor to deserialize.
Q: How do I serialize an enum as its name instead of a number?
Add a JsonStringEnumConverter to options.Converters, or tag the property with [JsonConverter(typeof(JsonStringEnumConverter))]. By default enums are written as their underlying integer.
Mini-Challenge: Round-Trip a List
No blanks this time — just a brief and an outline to keep you on track. Build a small TodoItem class, make a List<TodoItem>, serialize it to JSON, deserialize that JSON straight back into a fresh list, and print the first item's title. A clean round-trip — out to JSON and back, with nothing lost — is the everyday proof your model and your JSON agree. Run it and check your output against the expected line in the comments.
🎯 Mini-Challenge: round-trip a list of tasks
Serialize a List<TodoItem>, deserialize it back, and print the first title.
using System;
using System.Collections.Generic;
using System.Text.Json;
// 🎯 MINI-CHALLENGE: Round-trip a list of tasks
// 1. Define a class TodoItem with: string Title and bool Done (both get; set;).
// 2. Build a List<TodoItem> with at least two items.
// 3. Serialize the list to a JSON string (use WriteIndented = true).
// 4. Deserialize that JSON string back into a new List<TodoItem>.
// 5. Print the Title of the FIRST item from the deserialized list.
//
// Hints:
// - JsonSerializer
...🎉 Lesson Complete
- ✅
JsonSerializer.Serialize(obj)turns an object into a JSON string - ✅
JsonSerializer.Deserialize<T>(json)rebuilds a typed object (result is nullable) - ✅
JsonSerializerOptionscontrols format:WriteIndented, camelCase policy, case-insensitivity - ✅ STJ is case-sensitive by default — the cause of most "empty object" surprises
- ✅
[JsonPropertyName]renames a key;[JsonIgnore]drops a property - ✅
JsonDocument(read-only) andJsonNode(read-write) handle JSON with no class - ✅ Prefer
System.Text.Jsonfor new code;Newtonsoft.Jsonremains widely used - ✅ Next lesson: REST APIs with ASP.NET Core — where this JSON skill powers every request and response
Sign up for free to track which lessons you've completed and get learning reminders.