Working with JSON

JSON is the common language of web APIs, configuration files and data exchange. .NET's built-in System.Text.Json converts objects to JSON text (serialisation) and back (deserialisation), quickly and with a lot of control over the format.

A note on single-file apps

By default, System.Text.Json inspects your types at run time using reflection. Single-file apps run with dotnet run app.cs are set up for Native AOT compilation, which switches reflection-based JSON off — calling JsonSerializer.Serialize(obj) fails with "Reflection-based serialization has been disabled for this application". There are two ways to deal with it, and this lesson shows both:

  • add #:property PublishAot=false at the top of the file (the examples below do this). Regular projects created with dotnet new console don't need it;
  • or use source generation, which works everywhere, including Native AOT, and is faster (see the end of the lesson).

Serialising an object

JsonSerializer.Serialize turns an object into a JSON string. Public properties become JSON properties; collections become arrays:

C#
#:property PublishAot=false
using System.Text.Json;

var book = new Book("The Pragmatic Programmer", ["Andy Hunt", "Dave Thomas"], 1999, 4.6);
string json = JsonSerializer.Serialize(book);
Console.WriteLine(json);

record Book(string Title, string[] Authors, int Year, double Rating);
Output
{"Title":"The Pragmatic Programmer","Authors":["Andy Hunt","Dave Thomas"],"Year":1999,"Rating":4.6}

Options: indentation and naming

Pass a JsonSerializerOptions to control the output. JsonSerializerDefaults.Web gives you the conventions most web APIs use: camelCase property names and case-insensitive reading. Create the options object once and reuse it:

C#
#:property PublishAot=false
using System.Text.Json;

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true };
var book = new Book("Clean Code", ["Robert C. Martin"], 2008, 4.4);

Console.WriteLine(JsonSerializer.Serialize(book, options));

record Book(string Title, string[] Authors, int Year, double Rating);
Output
{
  "title": "Clean Code",
  "authors": [
    "Robert C. Martin"
  ],
  "year": 2008,
  "rating": 4.4
}

Deserialising

JsonSerializer.Deserialize<T> reads JSON into an object of type T. Records and classes with constructors work, as do classes with settable properties. The result is nullable, because the JSON text might be the literal null:

C#
#:property PublishAot=false
using System.Text.Json;

string json = """
    {
      "id": 42,
      "name": "Desk lamp",
      "price": 34.5,
      "tags": ["lighting", "office"],
      "stock": { "warehouse": "LIS-1", "quantity": 17 }
    }
    """;

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web);
Product? p = JsonSerializer.Deserialize<Product>(json, options);

Console.WriteLine(p?.Name);
Console.WriteLine(p?.Price * 2);
Console.WriteLine(string.Join(", ", p?.Tags ?? []));
Console.WriteLine($"{p?.Stock.Quantity} at {p?.Stock.Warehouse}");

record Product(int Id, string Name, decimal Price, List<string> Tags, StockInfo Stock);
record StockInfo(string Warehouse, int Quantity);
Output
Desk lamp
69.0
lighting, office
17 at LIS-1

Nested objects map to nested types, and JSON arrays map to arrays or lists. Properties in the JSON that your type doesn't have are ignored by default.

Invalid JSON, or values of the wrong type, throw a JsonException that says where the problem is:

C#
#:property PublishAot=false
using System.Text.Json;

try
{
    JsonSerializer.Deserialize<Point>("""{ "X": 1, "Y": "two" }""");
}
catch (JsonException ex)
{
    Console.WriteLine(ex.Message);
}

record Point(int X, int Y);
Output
The JSON value could not be converted to Point. Path: $.Y | LineNumber: 0 | BytePositionInLine: 20.

Customising with attributes

Attributes on properties control individual details: a different JSON name, leaving a property out, or writing an enum as text instead of a number:

C#
#:property PublishAot=false
using System.Text.Json;
using System.Text.Json.Serialization;

var user = new User { Id = 7, DisplayName = "sam_k", PasswordHash = "x9f…", Role = Role.Admin, Nickname = null };
Console.WriteLine(JsonSerializer.Serialize(user));

class User
{
    public int Id { get; set; }

    [JsonPropertyName("display_name")]
    public string DisplayName { get; set; } = "";

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

    [JsonConverter(typeof(JsonStringEnumConverter<Role>))]
    public Role Role { get; set; }

    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
    public string? Nickname { get; set; }
}

enum Role { Reader, Editor, Admin }
Output
{"Id":7,"display_name":"sam_k","Role":"Admin"}

Reading and writing JSON files

Combine the serializer with the file methods from the last lesson. For files, the stream-based overloads avoid building a big string in memory:

C#
#:property PublishAot=false
using System.Text.Json;

var options = new JsonSerializerOptions(JsonSerializerDefaults.Web) { WriteIndented = true };
var settings = new Settings("dark", 14, ["en", "pt"]);

using (FileStream output = File.Create("settings.json"))
{
    await JsonSerializer.SerializeAsync(output, settings, options);
}
Console.WriteLine(File.ReadAllText("settings.json"));

using FileStream input = File.OpenRead("settings.json");
Settings? loaded = await JsonSerializer.DeserializeAsync<Settings>(input, options);
Console.WriteLine(loaded == settings with { Languages = loaded!.Languages });

record Settings(string Theme, int FontSize, string[] Languages);
Output
{
  "theme": "dark",
  "fontSize": 14,
  "languages": [
    "en",
    "pt"
  ]
}
True

Exploring JSON without a class: JsonNode

When you don't have — or don't want — a matching type, parse JSON into a JsonNode tree and navigate it with indexers. You can also modify it and write it back out:

C#
using System.Text.Json.Nodes;

string json = """
    { "city": "Porto", "forecast": [ { "day": "Mon", "max": 21 }, { "day": "Tue", "max": 24 } ] }
    """;

JsonNode root = JsonNode.Parse(json)!;
Console.WriteLine(root["city"]);
foreach (JsonNode? day in root["forecast"]!.AsArray())
    Console.WriteLine($"{day!["day"]}: {(int)day["max"]!} °C");

root["city"] = "Lisbon";
root["updated"] = true;
Console.WriteLine(root.ToJsonString());
Output
Porto
Mon: 21 °C
Tue: 24 °C
{"city":"Lisbon","forecast":[{"day":"Mon","max":21},{"day":"Tue","max":24}],"updated":true}

For read-only, high-performance access there's also JsonDocument; for the fastest, lowest-level processing, Utf8JsonReader and Utf8JsonWriter.

Source generation

With source generation, the compiler writes the serialisation code for your types at build time. There's no reflection, so it's faster, uses less memory, and works with Native AOT — including single-file apps without any extra directive. Declare a partial class deriving from JsonSerializerContext and list your types with [JsonSerializable]:

C#
using System.Text.Json;
using System.Text.Json.Serialization;

var order = new Order(1001, "Ana", [new("Pen", 3), new("Notebook", 1)]);

string json = JsonSerializer.Serialize(order, AppJson.Default.Order);
Console.WriteLine(json);

Order? back = JsonSerializer.Deserialize(json, AppJson.Default.Order);
Console.WriteLine($"{back?.Customer} ordered {back?.Lines.Sum(l => l.Quantity)} items");

record Order(int Id, string Customer, List<OrderLine> Lines);
record OrderLine(string Product, int Quantity);

[JsonSourceGenerationOptions(JsonSerializerDefaults.Web)]
[JsonSerializable(typeof(Order))]
partial class AppJson : JsonSerializerContext;
Output
{"id":1001,"customer":"Ana","lines":[{"product":"Pen","quantity":3},{"product":"Notebook","quantity":1}]}
Ana ordered 4 items

AppJson.Default.Order is generated type information for Order, including the nested OrderLine. ASP.NET Core can be configured to use such contexts too, which is the recommended setup for AOT-compiled web APIs.

Try it yourself

Deserialise a list of tasks, mark the overdue ones as urgent, and serialise the result.

Try it yourself · Program.cs
Edit the code, copy it into Program.cs and run it with dotnet run Program.cs (.NET 10 or later). How to set up .NET
Output
[{"title":"Pay invoice","due":"2026-10-01","urgent":true},{"title":"Plan trip","due":"2026-12-20","urgent":false}]