Menu

C# JSON: Serialize and Deserialize with System.Text.Json

How to work with JSON in C# using System.Text.Json: JsonSerializer.Serialize and Deserialize, camelCase and indented output, attributes such as JsonPropertyName and JsonIgnore, enums as strings, reading JSON without classes via JsonDocument and JsonNode, errors, and how it compares to Newtonsoft.Json.

JSON is how most C# programs talk to web APIs, store settings and exchange data. Modern .NET reads and writes it with System.Text.Json, which is part of the runtime from .NET Core 3.0 on: no NuGet package is needed. Its main entry point is the static class JsonSerializer, which turns objects into JSON strings and back.

System.Text.Json is built into .NET Core 3.0 and every later release (.NET 5 through .NET 10). Projects on .NET Framework 4.6.2 and later, or on .NET Standard 2.0, can use it too by installing the System.Text.Json NuGet package. The examples on this page are shown as plain code with the output in comments. Add these using directives to run them in a .NET project:

using System.Text.Json;
using System.Text.Json.Serialization;

Serialize: object to JSON

JsonSerializer.Serialize writes every public property of an object, using the property names as they are:

public class Product
{
    public string Name { get; set; }
    public decimal Price { get; set; }
    public List<string> Tags { get; set; } = new List<string>();
    public bool InStock { get; set; }
}

var lamp = new Product { Name = "Desk lamp", Price = 34.90m, Tags = { "home", "light" }, InStock = true };

string json = JsonSerializer.Serialize(lamp);
Console.WriteLine(json);
// {"Name":"Desk lamp","Price":34.90,"Tags":["home","light"],"InStock":true}

Collections become arrays, dictionaries with string keys become objects ({"apples":3,"pears":5}), null stays null, and DateTime becomes an ISO 8601 string ("2026-03-01T14:30:00"). Two defaults catch people out:

  • Fields are skipped. Only properties are serialized. A class with public int X; serializes as {} unless you set IncludeFields = true in the options or turn the field into a property.
  • Enums are numbers. OrderStatus.Shipped is written as 1 (the enum's underlying value). Add JsonStringEnumConverter (below) to write "Shipped".

Deserialize: JSON to object

JsonSerializer.Deserialize<T> creates a T and sets its properties from the JSON:

string json = "{\"Name\":\"Desk lamp\",\"Price\":34.90,\"Tags\":[\"home\",\"light\"]}";
Product p = JsonSerializer.Deserialize<Product>(json);
Console.WriteLine($"{p.Name} {p.Price} {p.Tags.Count}");   // Desk lamp 34.90 2

var many = JsonSerializer.Deserialize<List<Product>>("[{\"Name\":\"A\",\"Price\":1},{\"Name\":\"B\",\"Price\":2.5}]");
Console.WriteLine(many.Count);                               // 2

JSON properties with no matching C# property are ignored, and C# properties with no matching JSON keep their default values. Neither is an error by default.

The rule that trips up nearly everyone: name matching is case-sensitive. Most web APIs send camelCase, and camelCase does not match PascalCase properties:

string fromApi = "{\"name\":\"Mug\",\"price\":8.5}";

var a = JsonSerializer.Deserialize<Product>(fromApi);
Console.WriteLine($"[{a.Name}] {a.Price}");    // [] 0: nothing matched, no error

var options = new JsonSerializerOptions { PropertyNameCaseInsensitive = true };
var b = JsonSerializer.Deserialize<Product>(fromApi, options);
Console.WriteLine($"[{b.Name}] {b.Price}");    // [Mug] 8.5

new JsonSerializerOptions(JsonSerializerDefaults.Web) gives the settings ASP.NET Core uses: case-insensitive reading, camelCase writing, and numbers accepted as quoted strings.

Deserialization needs a way to set each value: a public setter, an init accessor, or a constructor whose parameter names match the properties. That last rule is why records work out of the box:

public record Point(int X, int Y);

Point pt = JsonSerializer.Deserialize<Point>("{\"X\":1,\"Y\":2}");
Console.WriteLine(pt);   // Point { X = 1, Y = 2 }

Options: camelCase and indented output

JsonSerializerOptions controls naming, formatting and more. Create one instance and reuse it: the serializer caches metadata per options instance, so building new options for every call is measurably slower.

private static readonly JsonSerializerOptions Options = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
    WriteIndented = true,
};

Console.WriteLine(JsonSerializer.Serialize(lamp, Options));
// {
//   "name": "Desk lamp",
//   "price": 34.90,
//   "tags": [
//     "home",
//     "light"
//   ],
//   "inStock": true
// }

Other options worth knowing: DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull to leave out null properties, IncludeFields = true, NumberHandling to accept numbers written as strings, and ReadCommentHandling = JsonCommentHandling.Skip plus AllowTrailingCommas = true for hand-edited config files. .NET 8 added JsonNamingPolicy.SnakeCaseLower for APIs that use snake_case.

Attributes: rename, ignore, enums as strings

Attributes on the class control one property at a time and win over the options:

public enum OrderStatus { Pending, Shipped }

public class Order
{
    [JsonPropertyName("order_id")]
    public int Id { get; set; }

    public string Customer { get; set; }

    [JsonIgnore]
    public string InternalNote { get; set; }                  // never written or read

    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
    public string Coupon { get; set; }                        // left out when null

    [JsonConverter(typeof(JsonStringEnumConverter))]
    public OrderStatus Status { get; set; }

    public DateTime PlacedAt { get; set; }
}

var order = new Order
{
    Id = 1042, Customer = "Ana", InternalNote = "vip",
    Status = OrderStatus.Shipped, PlacedAt = new DateTime(2026, 3, 1, 14, 30, 0),
};
Console.WriteLine(JsonSerializer.Serialize(order));
// {"order_id":1042,"Customer":"Ana","Status":"Shipped","PlacedAt":"2026-03-01T14:30:00"}

To write every enum as a string instead of marking each property, add the converter to the options: options.Converters.Add(new JsonStringEnumConverter());.

Reading JSON without a class: JsonDocument and JsonNode

When you only need a few values from a large response, or its shape varies, skip the class. JsonDocument parses into a read-only tree of JsonElement values:

string weather = "{\"city\":\"Lisbon\",\"current\":{\"temp\":21.5,\"conditions\":[\"sunny\",\"windy\"]},\"alerts\":null}";

using (JsonDocument doc = JsonDocument.Parse(weather))
{
    JsonElement root = doc.RootElement;
    Console.WriteLine(root.GetProperty("city").GetString());                        // Lisbon
    Console.WriteLine(root.GetProperty("current").GetProperty("temp").GetDecimal()); // 21.5

    foreach (JsonElement c in root.GetProperty("current").GetProperty("conditions").EnumerateArray())
        Console.WriteLine(c.GetString());                                            // sunny, windy

    Console.WriteLine(root.TryGetProperty("humidity", out _));                       // False
    Console.WriteLine(root.GetProperty("alerts").ValueKind);                         // Null
}

GetProperty throws KeyNotFoundException for a missing name, so use TryGetProperty for optional fields. JsonDocument rents pooled memory, which is why it is disposed with using.

To modify JSON, use JsonNode from System.Text.Json.Nodes (.NET 6 and later), which gives a mutable tree with indexers:

JsonNode node = JsonNode.Parse(weather);
Console.WriteLine((string)node["city"]);   // Lisbon

node["current"]["temp"] = 23;
node["updated"] = true;
Console.WriteLine(node.ToJsonString());
// {"city":"Lisbon","current":{"temp":23,"conditions":["sunny","windy"]},"alerts":null,"updated":true}

Files and streams

JSON on disk is a string in a file, so the file methods compose directly with the serializer:

File.WriteAllText("settings.json", JsonSerializer.Serialize(settings, Options));
var loaded = JsonSerializer.Deserialize<Settings>(File.ReadAllText("settings.json"), Options);
// Same options both ways: camelCase names written with Options would not match on a default read.

For large files and HTTP bodies, the async stream overloads avoid building the whole string in memory:

await using FileStream stream = File.OpenRead("orders.json");
List<Order> orders = await JsonSerializer.DeserializeAsync<List<Order>>(stream);

In ASP.NET Core and with HttpClient, you rarely call the serializer yourself: controllers bind JSON bodies automatically, and httpClient.GetFromJsonAsync<Order>(url) (in System.Net.Http.Json) does the request and the deserialization in one call.

Errors

Invalid JSON, or a value that cannot be converted to the property's type, throws JsonException. Its message names the JSON path and position, which is usually enough to find the problem:

try
{
    JsonSerializer.Deserialize<Product>("{\"Name\": \"Lamp\", \"Price\": \"cheap\"}");
}
catch (JsonException e)
{
    Console.WriteLine(e.Message);
    // The JSON value could not be converted to System.Decimal. Path: $.Price | LineNumber: 0 | BytePositionInLine: 33.
}

Deserialize returns null (not an exception) when the JSON text is the literal null, so check the result when the input comes from outside.

Escaped characters in the output

By default, the serializer escapes non-ASCII characters and characters that are unsafe inside HTML:

Console.WriteLine(JsonSerializer.Serialize(new { city = "São Paulo", note = "5 > 3" }));
// {"city":"S\u00E3o Paulo","note":"5 \u003E 3"}

This is valid JSON and reads back as the original text; it is escaped so that the output can be dropped into an HTML page safely. For human-readable files, set Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping (from System.Text.Encodings.Web) in the options. The "unsafe" refers only to embedding the result in HTML.

System.Text.Json vs Newtonsoft.Json

Newtonsoft.Json (Json.NET, the JsonConvert class) was the standard for a decade and is still everywhere. The main differences:

System.Text.JsonNewtonsoft.Json
Availabilitybuilt into .NET Core 3.0+; NuGet package for .NET Framework 4.6.2+NuGet package for .NET Framework and .NET
Serialize / deserializeJsonSerializer.Serialize(obj) / Deserialize<T>(json)JsonConvert.SerializeObject(obj) / DeserializeObject<T>(json)
Name matchingcase-sensitive by defaultcase-insensitive
Untyped readingJsonDocument, JsonNodeJObject, JToken, with JSONPath queries
Rename a property[JsonPropertyName("x")][JsonProperty("x")]
Leniencystrict: no comments, trailing commas or quoted numbers unless enabledlenient by default
Performancefaster, fewer allocations, source generation for trimming and AOTslower, reflection-based

The attributes have different names and namespaces, so switching a codebase is a search-and-replace job plus tests for the stricter parsing. For new .NET code, start with System.Text.Json.

Source generation (.NET 6+)

JsonSerializer normally inspects your types with reflection at run time. For apps trimmed or compiled ahead of time (Native AOT, Blazor WebAssembly), a source generator writes that code at compile time instead:

[JsonSerializable(typeof(Product))]
internal partial class AppJsonContext : JsonSerializerContext { }

string json = JsonSerializer.Serialize(lamp, AppJsonContext.Default.Product);

Common mistakes

  • camelCase JSON into PascalCase properties with default options. Every property stays empty, silently. Use PropertyNameCaseInsensitive or JsonSerializerDefaults.Web.
  • Public fields instead of properties. They are not serialized unless IncludeFields is set.
  • Properties with no setter. A get-only property without a matching constructor parameter is not filled on deserialization.
  • A new JsonSerializerOptions per call. Reuse one static instance.
  • Floating-point money. A double of 0.1 + 0.2 serializes as 0.30000000000000004. Use decimal for amounts.

Frequently Asked Questions

How do I convert an object to JSON in C#?

Call JsonSerializer.Serialize(obj) from System.Text.Json, which is built into .NET Core 3.0 and later with no package to install. It writes every public property: {"Name":"Desk lamp","Price":34.90}. Pass new JsonSerializerOptions { WriteIndented = true } for readable output and PropertyNamingPolicy = JsonNamingPolicy.CamelCase for camelCase names.

How do I convert JSON to an object in C#?

var product = JsonSerializer.Deserialize<Product>(json); creates a Product and fills its public settable properties from the matching JSON names. Matching is case-sensitive by default, so camelCase JSON leaves PascalCase properties empty unless you pass PropertyNameCaseInsensitive = true or new JsonSerializerOptions(JsonSerializerDefaults.Web). Malformed JSON or a wrong value type throws JsonException.

How do I read JSON without creating a class in C#?

Use JsonDocument.Parse(json) and walk RootElement with GetProperty("name"), GetString(), GetInt32() and EnumerateArray(); it is read-only and fast, and must be disposed. For JSON you want to modify, JsonNode.Parse(json) (.NET 6+) gives a mutable tree: node["city"], assignments, and ToJsonString().

Should I use System.Text.Json or Newtonsoft.Json?

For new code on .NET Core 3.0 or later, System.Text.Json: it is built in, faster, allocates less, and ASP.NET Core uses it by default. Newtonsoft.Json (Json.NET) is still common in .NET Framework projects (where System.Text.Json is available only as a NuGet package), in code that depends on its extra features (JSONPath queries, very lenient parsing, TypeNameHandling), and in large codebases already built on it.

Why does System.Text.Json escape characters like é and <?

The default encoder escapes non-ASCII and HTML-sensitive characters (<, >, &, ') as \uXXXX, so the output is safe to embed in HTML. It is still valid JSON and deserializes back to the same text. For readable output, set Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping in the options, but only when the JSON is not written into HTML.

Coddy programming languages illustration

Learn to code with Coddy

GET STARTED