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=falseat the top of the file (the examples below do this). Regular projects created withdotnet new consoledon'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:
#: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);
{"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:
#: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);
{
"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:
#: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);
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:
#: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);
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:
#: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 }
{"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:
#: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);
{
"theme": "dark",
"fontSize": 14,
"languages": [
"en",
"pt"
]
}
TrueExploring 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:
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());
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]:
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;
{"id":1001,"customer":"Ana","lines":[{"product":"Pen","quantity":3},{"product":"Notebook","quantity":1}]}
Ana ordered 4 itemsAppJson.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.
[{"title":"Pay invoice","due":"2026-10-01","urgent":true},{"title":"Plan trip","due":"2026-12-20","urgent":false}]