JSON Processing with System.Text.Json
Reviewed & published by Brayan K
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.
Part of the free C# course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.
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
💡 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.
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 & Thomas",
Year = 1999,
InStock = true
};
// 2) Serialize = turn the object INTO a JSON string.
// Default output is compact, one line, PascalCase keys.
string compact = JsonSerializer.Serialize(book);
Console.WriteLine(compact);
// {"Title":"The Pragmatic Programmer","Author":"Hunt & Thomas","Year":1999,"InStock":true}
// 3) Options control the FORMAT. WriteIndented = human-readable;
// CamelCase = the casing most web APIs expect (title, not Title).
var options = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase
};
string pretty = JsonSerializer.Serialize(book, options);
Console.WriteLine(pretty);
// {
// "title": "The Pragmatic Programmer",
// "author": "Hunt & Thomas",
// "year": 1999,
// "inStock": true
// }
}
}
// ✅ Expected output:
// {"Title":"The Pragmatic Programmer","Author":"Hunt \u0026 Thomas","Year":1999,"InStock":true}
// {
// "title": "The Pragmatic Programmer",
// "author": "Hunt \u0026 Thomas",
// "year": 1999,
// "inStock": true
// }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.
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 = ___ // 👉 set this to true to indent the output
};
// 3) Serialize the object to a JSON string.
string json = JsonSerializer.___(movie, options); // 👉 the method is Serialize
Console.WriteLine(json);
// ✅ Expected output:
// {
// "Title": "Inception",
// "Year": 2010
// }
}
}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.
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 = "{\"city\":\"London\",\"tempC\":14.5,\"isRaining\":true}";
// Deserialize = turn the JSON string INTO a C# object.
// Note: keys here are camelCase but the C# properties are PascalCase.
// PropertyNameCaseInsensitive = true tells the parser to match
// "city" -> City regardless of casing (otherwise City stays empty!).
var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
Weather? w = JsonSerializer.Deserialize<Weather>(json, options);
// w could be null if the JSON was literally "null", so use ?. to be safe.
Console.WriteLine($"City: {w?.City}"); // City: London
Console.WriteLine($"Temp: {w?.TempC}C"); // Temp: 14.5C
Console.WriteLine($"Raining: {w?.IsRaining}"); // Raining: True
}
}
// ✅ Expected output:
// City: London
// Temp: 14.5C
// Raining: TrueNow you try. Deserialize the JSON into a User, then read a property off the object you built. Fill in the two ___ blanks:
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 type is User
// 2) Read the Name property off the object you just built.
Console.WriteLine($"Name: {user?.___}"); // 👉 the property is Name
Console.WriteLine($"Age: {user?.Age}");
// ✅ Expected output:
// Name: Ada
// Age: 36
}
}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.
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 OUT of the JSON entirely — perfect for
// secrets or values you compute and never want to send.
[JsonIgnore]
public string SessionToken { get; set; } = "";
}
class Program
{
static void Main()
{
// Deserialize: snake_case keys land in the PascalCase properties.
string json = "{\"first_name\":\"Grace\",\"last_name\":\"Hopper\"}";
var acct = JsonSerializer.Deserialize<Account>(json);
Console.WriteLine($"{acct?.FirstName} {acct?.LastName}"); // Grace Hopper
// Serialize back: you get snake_case keys out, and SessionToken
// is skipped because of [JsonIgnore].
acct!.SessionToken = "abc-secret-123";
string outJson = JsonSerializer.Serialize(acct);
Console.WriteLine(outJson);
// {"first_name":"Grace","last_name":"Hopper"}
}
}
// ✅ Expected output:
// Grace Hopper
// {"first_name":"Grace","last_name":"Hopper"}🔎 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.
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.
using (JsonDocument doc = JsonDocument.Parse(json))
{
JsonElement root = doc.RootElement;
string name = root.GetProperty("name").GetString()!;
int stars = root.GetProperty("stars").GetInt32();
Console.WriteLine($"{name} has {stars} stars"); // Repo has 1280 stars
// Walk an array element-by-element.
foreach (JsonElement tag in root.GetProperty("tags").EnumerateArray())
Console.WriteLine($" tag: {tag.GetString()}");
}
// === JsonNode — read AND write a dynamic tree ===
// Use this when you want to edit JSON without a model class.
JsonNode node = JsonNode.Parse(json)!;
node["stars"] = 9999; // change a value
node["owner"] = "octocat"; // add a brand-new key
Console.WriteLine(node.ToJsonString());
// {"name":"Repo","stars":9999,"tags":["csharp","json","dotnet"],"owner":"octocat"}
}
}
// ✅ Expected output:
// Repo has 1280 stars
// tag: csharp
// tag: json
// tag: dotnet
// {"name":"Repo","stars":9999,"tags":["csharp","json","dotnet"],"owner":"octocat"}🔎 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. JsonConvert settings, certain attributes, LINQ to JSON niceties) 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 JsonSerializerOptions once and reuse it. STJ caches metadata on the instance; a new options object per call silently kills that cache.
- 💡 Dispose JsonDocument with a using — 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 fill City by default. Set PropertyNameCaseInsensitive = 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 null with no error: the input was literally the text null, 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 null list, 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.
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.Serialize(list, options)
// - JsonSerializer.Deserialize<List<TodoItem>>(json)
// - read the first item with result[0].Title
//
// ✅ Expected (if the first item's Title is "Write code"):
// First task: Write code
class TodoItem
{
// your properties here
}
class Program
{
static void Main()
{
// your code here
}
}🎉 Lesson Complete
- ✅ JsonSerializer.Serialize(obj) turns an object into a JSON string
- ✅ JsonSerializer.Deserialize<T>(json) rebuilds a typed object (result is nullable)
- ✅ JsonSerializerOptions controls 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) and JsonNode (read-write) handle JSON with no class
- ✅ Prefer System.Text.Json for new code; Newtonsoft.Json remains widely used
Practice quiz
What does JsonSerializer.Serialize(obj) do?
- Turns a JSON string into a C# object
- Validates a JSON schema
- Turns a C# object into a JSON string
- Reads JSON from a file
Answer: Turns a C# object into a JSON string. Serialize turns a live C# object into a JSON string; Deserialize is the reverse direction.
By default, is System.Text.Json case-sensitive when matching JSON keys to properties?
- Yes, it is case-sensitive by default
- No, it ignores case by default
- Only for arrays
- Only when WriteIndented is true
Answer: Yes, it is case-sensitive by default. STJ is case-sensitive by default, so JSON key 'city' won't fill property City unless you set PropertyNameCaseInsensitive = true.
What option produces pretty, multi-line JSON output?
- PropertyNameCaseInsensitive = true
- IncludeFields = true
- CamelCase = true
- WriteIndented = true
Answer: WriteIndented = true. WriteIndented = true on JsonSerializerOptions makes the output human-readable with indentation.
What is the return type of JsonSerializer.Deserialize<Weather>(json)?
- Weather (never null)
- Weather? (nullable, because the JSON could be null)
- string
- object
Answer: Weather? (nullable, because the JSON could be null). Deserialize returns a nullable type (Weather?) because the JSON could literally be the text null, so you should null-check the result.
What does the [JsonPropertyName("first_name")] attribute do?
- Maps the exact JSON key 'first_name' to a C# property, both reading and writing
- Hides the property from JSON
- Renames the C# class
- Forces the property to be a string
Answer: Maps the exact JSON key 'first_name' to a C# property, both reading and writing. [JsonPropertyName] maps a specific JSON key (like snake_case 'first_name') to a tidy C# property in both directions.
What does the [JsonIgnore] attribute do?
- Includes private fields
- Encrypts the property
- Leaves the property out of the JSON entirely
- Forces camelCase naming
Answer: Leaves the property out of the JSON entirely. [JsonIgnore] excludes a property from the JSON — ideal for secrets or computed values you never want serialized.
Which tool reads JSON without a model class, read-only and low-allocation?
- JsonNode
- JsonDocument
- JsonSerializer
- Utf8JsonWriter
Answer: JsonDocument. JsonDocument parses JSON into a read-only tree of JsonElements with few allocations; wrap it in using to return its pooled memory.
Which tool lets you read AND edit dynamic JSON without a model class?
- JsonDocument
- JsonSerializer
- JsonElement
- JsonNode
Answer: JsonNode. JsonNode is the read-write cousin of JsonDocument: parse it, then change values or add keys with indexer syntax like node["stars"] = 9999.
Why should you create JsonSerializerOptions once and reuse it?
- It is required by the compiler
- STJ caches type metadata on the instance, so a new one per call throws that cache away
- It changes the JSON output
- It enables async serialization
Answer: STJ caches type metadata on the instance, so a new one per call throws that cache away. STJ caches type metadata against the options instance; creating a fresh options object on every call discards that cache and slows you down.
What does System.Text.Json require to deserialize into a class by default?
- A constructor that takes all properties
- Private fields only
- A public parameterless constructor and public settable properties
- An [JsonSerializable] attribute
Answer: A public parameterless constructor and public settable properties. By default STJ builds the object with a public parameterless constructor and sets public properties; it ignores private members unless you opt in.
Continue this course
- Previous: Working with Files: Streams, Buffers, Pipelines
- Next: Building REST APIs with ASP.NET Core (Advanced) — Minimal APIs, controller patterns, model binding, and versioning
- Quick reference: C# cheat sheet