C# JSON with System.Text.Json: JsonSerializer, JsonNode & Options

Last updated:

C# JSON serialization uses System.Text.Json (built into .NET 6+) — JsonSerializer.Serialize(obj) converts a C# object to a JSON string, and JsonSerializer.Deserialize<MyClass>(jsonStr) parses JSON into a typed object, with source generation for AOT-safe, zero-reflection serialization. System.Text.Json benchmarks at 2× the throughput of Newtonsoft.Json for serialization and 1.5× for deserialization — it also uses 40% less memory by avoiding intermediate string allocations and using Span<byte> for UTF-8 processing. This guide covers JsonSerializerOptions configuration, [JsonPropertyName] and [JsonIgnore] attributes, JsonNode for dynamic JSON manipulation, custom JsonConverter<T>, source generation with JsonSerializerContext, and ASP.NET Core minimal API JSON integration.

JsonSerializer.Serialize and Deserialize Basics

JsonSerializer.Serialize<T>() and Deserialize<T>() are the primary entry points for typed JSON in C#. Both methods provide overloads for strings, streams, and Utf8JsonWriter, enabling efficient UTF-8 byte processing without intermediate string allocation. Async variants (SerializeAsync/DeserializeAsync) integrate with .NET's cancellation token model for non-blocking stream I/O. Always catch JsonException for malformed input — it is thrown for invalid JSON, type mismatches, and missing required properties.

using System.Text.Json;

// ── Basic serialization ────────────────────────────────────────
public record UserDto(string Name, string Email, int Age);

var user = new UserDto("Alice", "alice@example.com", 30);

// Serialize to JSON string
string json = JsonSerializer.Serialize(user);
// {"Name":"Alice","Email":"alice@example.com","Age":30}

// Serialize to UTF-8 byte array (no intermediate string — faster)
byte[] utf8Bytes = JsonSerializer.SerializeToUtf8Bytes(user);

// Serialize to stream (async — integrates with HTTP responses)
using var stream = File.OpenWrite("user.json");
await JsonSerializer.SerializeAsync(stream, user);

// ── Basic deserialization ──────────────────────────────────────

// Deserialize from JSON string — returns T? (nullable!)
UserDto? parsed = JsonSerializer.Deserialize<UserDto>(json);
if (parsed is null) throw new InvalidOperationException("Null result");

// Deserialize from stream (async — integrates with HTTP requests)
using var readStream = File.OpenRead("user.json");
UserDto? fromFile = await JsonSerializer.DeserializeAsync<UserDto>(readStream);

// ── Error handling ────────────────────────────────────────────
try
{
    var bad = JsonSerializer.Deserialize<UserDto>("{invalid json}");
}
catch (JsonException ex)
{
    // ex.Message describes the parse error
    // ex.Path points to the failing JSON token
    Console.WriteLine($"JSON error at {ex.Path}: {ex.Message}");
}

// ── Nullable reference type handling ─────────────────────────
// Missing JSON keys leave C# properties at their default (null)
// Use required modifier (.NET 7+) to enforce presence
public record StrictUser(
    required string Name,   // throws JsonException if "Name" is absent
    string? Email           // null if "Email" is absent — safe
);

// ── Utf8JsonWriter for custom streaming output ─────────────────
using var ms = new MemoryStream();
using var writer = new Utf8JsonWriter(ms, new JsonWriterOptions { Indented = true });
writer.WriteStartObject();
writer.WriteString("name", "Alice");
writer.WriteNumber("age", 30);
writer.WriteEndObject();
writer.Flush();
string output = System.Text.Encoding.UTF8.GetString(ms.ToArray());

The Deserialize<T> return type is always nullable (T?) because the JSON input might be the literal string null, which deserializes to a null reference. In practice, always null-check the result or use the non-nullable pattern-match (is not null). For async deserialization from HTTP responses, use HttpContent.ReadFromJsonAsync<T>() from the System.Net.Http.Json package — it wraps DeserializeAsync with the correct content-type check and cancellation token threading.

JsonSerializerOptions Configuration

JsonSerializerOptions controls every aspect of serialization and deserialization behavior — naming policy, null handling, number coercion, comment tolerance, and more. The single most important rule: create one static readonly instance and reuse it everywhere. JsonSerializerOptionsis mutable during construction but becomes frozen (and thread-safe) after the first call that uses it — creating new instances per-call rebuilds all internal caches and is 10-50× slower.

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

// ── CORRECT: static readonly — built once, reused everywhere ───
private static readonly JsonSerializerOptions s_options = new()
{
    // Naming policy: PascalCase C# properties → camelCase JSON keys
    PropertyNamingPolicy         = JsonNamingPolicy.CamelCase,

    // Deserialization: match JSON keys case-insensitively
    PropertyNameCaseInsensitive  = true,

    // Null handling: omit properties that are null from output
    DefaultIgnoreCondition       = JsonIgnoreCondition.WhenWritingNull,

    // Lenient parsing: allow trailing commas and // comments
    AllowTrailingCommas          = true,
    ReadCommentHandling          = JsonCommentHandling.Skip,

    // Number coercion: parse "42" (JSON string) as an integer
    NumberHandling               = JsonNumberHandling.AllowReadingFromString,

    // Output: pretty-print with indentation
    WriteIndented                = true,
};

// ── WRONG: new options every call — rebuilds caches each time ─
// string json = JsonSerializer.Serialize(obj, new JsonSerializerOptions { ... })

// ── .NET 8+: pre-built options for web APIs ───────────────────
// JsonSerializerOptions.Web = CamelCase + case-insensitive + AllowReadingFromString
string json = JsonSerializer.Serialize(user, JsonSerializerOptions.Web);

// ── Snake_case naming policy (.NET 8+) ────────────────────────
var snakeOptions = new JsonSerializerOptions
{
    PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower,
    // { "user_name": "Alice", "user_email": "alice@example.com" }
};

// ── Enum serialization as strings ────────────────────────────
var enumOptions = new JsonSerializerOptions
{
    Converters = { new JsonStringEnumConverter(JsonNamingPolicy.CamelCase) },
    // Status.Active → "active" (not 0)
};

// ── Reusing options across typed calls ───────────────────────
public class JsonService
{
    private static readonly JsonSerializerOptions Options = new()
    {
        PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
        DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    };

    public string Serialize<T>(T value)  => JsonSerializer.Serialize(value, Options);
    public T? Deserialize<T>(string json) => JsonSerializer.Deserialize<T>(json, Options);
}

NumberHandling = JsonNumberHandling.AllowReadingFromString is essential when consuming third-party APIs that encode numbers as JSON strings — a common pattern in older Java and PHP APIs. JsonNamingPolicy.SnakeCaseLower and JsonNamingPolicy.KebabCaseLower were added in .NET 8; for .NET 6-7, implement a custom JsonNamingPolicy subclass. The JsonSerializerOptions.Default static property returns a frozen default instance — use it as a baseline and copy it with the copy constructor new JsonSerializerOptions(JsonSerializerOptions.Default) if you need to extend defaults.

Attributes: [JsonPropertyName], [JsonIgnore], [JsonInclude]

System.Text.Json attributes provide per-property serialization control without requiring global options changes. [JsonPropertyName] maps a C# property to a specific JSON key, [JsonIgnore] excludes a property from JSON, and [JsonInclude] exposes non-public properties. These attributes take precedence over any naming policy set in JsonSerializerOptions, making them the right tool for properties whose JSON key differs from the C# naming convention — for example, third-party API fields with snake_case or abbreviated names.

using System.Text.Json.Serialization;

public class UserProfile
{
    // Map C# PascalCase property to JSON snake_case key
    [JsonPropertyName("user_id")]
    public int UserId { get; set; }

    // Map to abbreviated JSON key used by the API
    [JsonPropertyName("ts")]
    public DateTimeOffset CreatedAt { get; set; }

    // Exclude this property from JSON output entirely
    [JsonIgnore]
    public string PasswordHash { get; set; } = "";

    // Exclude only when the value is null (keep when non-null)
    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
    public string? MiddleName { get; set; }

    // Exclude only when the value is the default (0 for int, false for bool)
    [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
    public int RetryCount { get; set; }

    // Include a non-public property in JSON (must have public getter)
    [JsonInclude]
    internal string InternalTag { get; set; } = "v2";

    // Apply a per-property custom converter (overrides global options)
    [JsonConverter(typeof(UnixEpochDateTimeConverter))]
    public DateTime UpdatedAt { get; set; }

    // Normal property — follows the global naming policy
    public string Email { get; set; } = "";
}

// Resulting JSON with CamelCase naming policy:
// {
//   "user_id": 42,           ← [JsonPropertyName] overrides naming policy
//   "ts": "2026-05-20T...", ← abbreviated key
//   "email": "alice@..."    ← CamelCase policy applied
//   // PasswordHash absent   ← [JsonIgnore]
//   // MiddleName absent     ← [JsonIgnore(WhenWritingNull)] + value is null
// }

// ── [JsonPropertyOrder] — control key order in output (.NET 7+) ──
public class OrderedResponse
{
    [JsonPropertyOrder(-1)]  // First in output
    public string Status { get; set; } = "";

    public string Data { get; set; } = "";

    [JsonPropertyOrder(1)]   // Last in output
    public string RequestId { get; set; } = "";
}

// ── [JsonRequired] — throw if JSON key is missing (.NET 7+) ──────
public class RequiredFields
{
    [JsonRequired]
    public string Name { get; set; } = "";  // throws JsonException if absent
    public string? Description { get; set; }
}

When both a [JsonPropertyName] attribute and a global PropertyNamingPolicy are set, the attribute always wins — the naming policy is not applied to annotated properties. [JsonIgnore] without a Condition argument always excludes the property regardless of its value; use JsonIgnoreCondition.WhenWritingNull for optional API fields that should be omitted when empty. For record types with positional constructors, place attributes on the constructor parameter — System.Text.Json maps constructor parameters to JSON keys for deserialization.

JsonNode and JsonDocument: Dynamic JSON

JsonNode and JsonDocument provide DOM-based JSON access without requiring a typed C# class. JsonNode.Parse() returns a mutable tree (JsonObject, JsonArray, or JsonValue) — use it when you need to read, modify, and re-serialize dynamic JSON. JsonDocument.Parse() returns a read-only, zero-allocation DOM optimized for parsing large payloads where you only need to extract specific fields — it must be disposed of when done to return pooled memory.

using System.Text.Json;
using System.Text.Json.Nodes;

string json = """
{
  "id": 42,
  "name": "Alice",
  "tags": ["admin", "editor"],
  "address": { "city": "Seattle", "zip": "98101" }
}
""";

// ── JsonNode: mutable DOM ─────────────────────────────────────
JsonNode? root = JsonNode.Parse(json);

// Read values — GetValue<T>() throws if wrong type
string name  = root!["name"]!.GetValue<string>();   // "Alice"
int    id    = root["id"]!.GetValue<int>();          // 42

// Navigate nested objects
string city = root["address"]!["city"]!.GetValue<string>(); // "Seattle"

// Navigate arrays
JsonArray tags = root["tags"]!.AsArray();
string firstTag = tags[0]!.GetValue<string>(); // "admin"

// Mutate the DOM
root["name"] = JsonValue.Create("Bob");           // overwrite
root["active"] = JsonValue.Create(true);          // add new key
root["address"]!["state"] = JsonValue.Create("WA");

// Re-serialize modified DOM
string updated = root.ToJsonString(new JsonSerializerOptions { WriteIndented = true });

// Build JSON from scratch with JsonObject
var newObj = new JsonObject
{
    ["userId"] = 99,
    ["email"]  = "carol@example.com",
    ["roles"]  = new JsonArray("user", "viewer"),
};
string built = newObj.ToJsonString();

// ── JsonDocument: read-only, zero-allocation DOM ───────────────
// MUST use 'using' — JsonDocument rents memory from a pool
using JsonDocument doc = JsonDocument.Parse(json);

JsonElement root2 = doc.RootElement;

// Safe property access — TryGetProperty returns false if key absent
if (root2.TryGetProperty("name", out JsonElement nameEl))
{
    string? safeName = nameEl.GetString(); // null if JSON null
}

// Navigate arrays
JsonElement tagsEl = root2.GetProperty("tags");
foreach (JsonElement tag in tagsEl.EnumerateArray())
{
    Console.WriteLine(tag.GetString());
}

// Enumerate object properties
foreach (JsonProperty prop in root2.EnumerateObject())
{
    Console.WriteLine($"{prop.Name}: {prop.Value}");
}

// Extract a sub-element and deserialize it to a typed class
JsonElement addressEl = root2.GetProperty("address");
Address? addr = addressEl.Deserialize<Address>();

JsonDocument is 3-5× more memory-efficient than JsonNode for read-only access because it rents a pooled byte buffer rather than allocating individual node objects. The trade-off is that JsonElement values become invalid after the parent JsonDocument is disposed — do not store JsonElement references beyond the using block. Use JsonElement.Clone() to get an independent copy if you need to outlive the document. For performance-critical paths that extract only a few fields from large JSON payloads, JsonDocument with TryGetProperty is the fastest managed option short of writing a custom Utf8JsonReader state machine.

Custom JsonConverter<T>

Custom converters let you control exactly how a specific type is serialized and deserialized — overriding the default mapping for DateTime, Guid, enums, union types, or any domain object. Implement JsonConverter<T> with a Read method (deserialization) and a Write method (serialization). Register converters globally in JsonSerializerOptions.Converters or per-property with the [JsonConverter] attribute.

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

// ── Example 1: DateTime as Unix epoch integer ─────────────────
public class UnixEpochDateTimeConverter : JsonConverter<DateTime>
{
    public override DateTime Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options)
    {
        // JSON number → Unix timestamp → DateTime
        long epochSeconds = reader.GetInt64();
        return DateTimeOffset.FromUnixTimeSeconds(epochSeconds).UtcDateTime;
    }

    public override void Write(
        Utf8JsonWriter writer,
        DateTime value,
        JsonSerializerOptions options)
    {
        // DateTime → Unix timestamp → JSON number
        long epochSeconds = new DateTimeOffset(value).ToUnixTimeSeconds();
        writer.WriteNumberValue(epochSeconds);
    }
}

// ── Example 2: Discriminated union (polymorphic deserialization) ──
// Without $type support in STJ, use a manual discriminator
public abstract record Shape(string Type);
public record Circle(string Type, double Radius) : Shape(Type);
public record Rectangle(string Type, double Width, double Height) : Shape(Type);

public class ShapeConverter : JsonConverter<Shape>
{
    public override Shape? Read(
        ref Utf8JsonReader reader,
        Type typeToConvert,
        JsonSerializerOptions options)
    {
        // Clone reader to peek at the "type" discriminator
        using var doc = JsonDocument.ParseValue(ref reader);
        string? type = doc.RootElement.GetProperty("type").GetString();

        return type switch
        {
            "circle"    => doc.RootElement.Deserialize<Circle>(options),
            "rectangle" => doc.RootElement.Deserialize<Rectangle>(options),
            _ => throw new JsonException($"Unknown shape type: {type}"),
        };
    }

    public override void Write(
        Utf8JsonWriter writer,
        Shape value,
        JsonSerializerOptions options)
    {
        // Serialize the concrete type — preserves all fields
        JsonSerializer.Serialize(writer, (object)value, options);
    }
}

// ── Registration: global via options ──────────────────────────
var options = new JsonSerializerOptions();
options.Converters.Add(new UnixEpochDateTimeConverter());
options.Converters.Add(new ShapeConverter());

// ── Registration: per-property via attribute ──────────────────
public class ApiResponse
{
    [JsonConverter(typeof(UnixEpochDateTimeConverter))]
    public DateTime CreatedAt { get; set; }

    public string Name { get; set; } = "";
}

// ── .NET 7+: [JsonPolymorphic] as a built-in alternative ──────
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(Circle), "circle")]
[JsonDerivedType(typeof(Rectangle), "rectangle")]
public abstract record ShapeV2(string Name);
// Serializes as: { "$type": "circle", "radius": 5.0, "name": "..." }

In .NET 7+, the [JsonPolymorphic] and [JsonDerivedType] attributes provide built-in discriminated union support without a custom converter — use them for new code. For null handling inside converters, check reader.TokenType == JsonTokenType.Null before reading a value and return the appropriate default. Never call JsonSerializer.Deserialize<T> inside a JsonConverter<T> for the same type T — it causes infinite recursion. Use JsonDocument.ParseValue(ref reader) to consume the reader state first, then deserialize from the document element.

Source Generation: JsonSerializerContext for AOT

Source generation eliminates runtime reflection by generating serialization code at compile time. This is required for Native AOT (Ahead-of-Time) compilation — used in Blazor WASM, .NET Native, and ARM64 native apps — where reflection over arbitrary types is prohibited. The [JsonSerializable] attribute on a JsonSerializerContext subclass triggers the Roslyn source generator to emit full serialization logic at build time.

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

// ── Step 1: Define the types to serialize ────────────────────
public record UserDto(string Name, string Email, int Age);
public record OrderDto(int Id, string Product, decimal Total);

// ── Step 2: Create the JsonSerializerContext ───────────────────
// Stack multiple [JsonSerializable] for all types you need
// Add [JsonSerializable] for collections separately
[JsonSerializable(typeof(UserDto))]
[JsonSerializable(typeof(OrderDto))]
[JsonSerializable(typeof(List<UserDto>))]
[JsonSerializable(typeof(List<OrderDto>))]
[JsonSourceGenerationOptions(
    PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase,
    DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
    GenerationMode = JsonSourceGenerationMode.Serialization   // zero-reflection mode
)]
internal partial class AppJsonContext : JsonSerializerContext { }

// ── Step 3: Use the generated context ─────────────────────────

var user = new UserDto("Alice", "alice@example.com", 30);

// Serialize — pass the generated TypeInfo directly
string json = JsonSerializer.Serialize(user, AppJsonContext.Default.UserDto);

// Deserialize
UserDto? parsed = JsonSerializer.Deserialize(json, AppJsonContext.Default.UserDto);

// Collections
var users = new List<UserDto> { user };
string jsonList = JsonSerializer.Serialize(users, AppJsonContext.Default.ListUserDto);

// ── AOT compatibility check ────────────────────────────────────
// The trimming analyzer (ILLink) emits warnings for types that flow
// through JsonSerializer without a registered context:
// warning IL2026: Using member 'JsonSerializer.Serialize<T>' ...
// Fix: always pass the TypeInfo from the context, not just options

// ── Combining source-generated context with minimal APIs ───────
// In Program.cs:
// app.MapGet("/users", () => TypedResults.Json(GetUsers(), AppJsonContext.Default));

// ── Metadata mode vs. serialization-optimized mode ────────────
// GenerationMode.Metadata:        generates type info, still uses some reflection
// GenerationMode.Serialization:   generates full read/write code — required for Native AOT
// GenerationMode.Default:         generates both (largest output, most compatible)

// ── Extending a context with a custom converter ─────────────────
[JsonSerializable(typeof(DateTime))]
[JsonSourceGenerationOptions(Converters = new[] { typeof(UnixEpochDateTimeConverter) })]
internal partial class ExtendedJsonContext : JsonSerializerContext { }

The trimming analyzer emits IL2026 and IL3050 warnings when JsonSerializer is called without a source-generated TypeInfo — these warnings indicate AOT-unsafe code paths. Fix every warning by passing the TypeInfo from your context. If a third-party library calls JsonSerializer internally without source generation, you cannot make it AOT-safe without patching the library — evaluate whether Newtonsoft.Json or a different serialization strategy is more appropriate in that case. For .NET 8+ minimal APIs, TypedResults.Json(obj, MyContext.Default) automatically uses source generation.

ASP.NET Core JSON: Minimal APIs and Controllers

ASP.NET Core uses System.Text.Json by default for both request body deserialization and response serialization. Minimal APIs and MVC controllers share the JSON infrastructure but are configured through different options APIs. The framework automatically serializes returned C# objects to JSON and deserializes JSON request bodies to typed parameters — no manual JsonSerializer calls needed for standard request/response handling.

// ── Program.cs — global JSON options for minimal APIs ─────────
using System.Text.Json;
using System.Text.Json.Serialization;

var builder = WebApplication.CreateBuilder(args);

// Configure JSON for minimal APIs (IHttpJsonOptions)
builder.Services.ConfigureHttpJsonOptions(options =>
{
    options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
    options.SerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
    options.SerializerOptions.Converters.Add(new JsonStringEnumConverter());
});

// Configure JSON for MVC controllers (JsonOptions)
builder.Services.AddControllers().AddJsonOptions(options =>
{
    options.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
    options.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
});

var app = builder.Build();

// ── Minimal API endpoints ──────────────────────────────────────

// Returning a typed object → auto-serialized to JSON
// GET /users/42 → {"id":42,"name":"Alice","email":"alice@example.com"}
app.MapGet("/users/{id:int}", (int id) =>
    new UserDto(id, "Alice", "alice@example.com"));

// Reading a JSON request body → auto-deserialized
// POST /users with body {"name":"Bob","email":"bob@example.com","age":25}
app.MapPost("/users", (UserDto user) =>
{
    // user is already deserialized — no manual JsonSerializer call needed
    return Results.Created($"/users/1", user);
});

// Results.Json — override options for a specific endpoint
app.MapGet("/users/{id}/raw", (int id) =>
{
    var user = new UserDto(id, "Alice", "alice@example.com");
    return Results.Json(user, new JsonSerializerOptions { WriteIndented = true });
});

// TypedResults.Json — with source-generated context (AOT-safe)
app.MapGet("/users/{id}/aot", (int id) =>
{
    var user = new UserDto(id, "Alice", "alice@example.com");
    return TypedResults.Json(user, AppJsonContext.Default.UserDto);
});

// ── MVC controller ────────────────────────────────────────────
[ApiController]
[Route("[controller]")]
public class UsersController : ControllerBase
{
    // [ApiController] auto-deserializes JSON body and returns 400 on validation error
    [HttpPost]
    public IActionResult Create([FromBody] UserDto user)
    {
        // user is deserialized from the JSON request body
        return CreatedAtAction(nameof(GetById), new { id = 1 }, user);
    }

    [HttpGet("{id:int}")]
    public ActionResult<UserDto> GetById(int id)
    {
        // ActionResult<T> auto-serializes T to JSON with 200 status
        return new UserDto(id, "Alice", "alice@example.com");
    }
}

// ── Switching to Newtonsoft.Json (not recommended for new code) ─
// dotnet add package Microsoft.AspNetCore.Mvc.NewtonsoftJson
// builder.Services.AddControllers().AddNewtonsoftJson(options => {
//     options.SerializerSettings.ContractResolver =
//         new CamelCasePropertyNamesContractResolver();
// });

ASP.NET Core minimal APIs and MVC controllers use separate options registrations — ConfigureHttpJsonOptions for minimal APIs and AddJsonOptions for controllers. If you configure both, ensure they are consistent or extract shared settings into a helper. Custom JsonConverter instances registered in either options are applied globally to all endpoints using that pipeline. For streaming large JSON responses (e.g., exporting thousands of records), use JsonSerializer.SerializeAsync directly to the response stream rather than returning a full in-memory collection — this avoids buffering the entire payload in memory. See the JSON parsing performance guide for streaming patterns.

Key Terms

JsonSerializer
The static entry-point class in System.Text.Json for converting between C# objects and JSON. Its two primary methods are Serialize<T>(T value, JsonSerializerOptions? options) (returns a JSON string) and Deserialize<T>(string json, JsonSerializerOptions? options) (returns T?). Overloads accept Stream, Utf8JsonReader, Utf8JsonWriter, and ReadOnlySpan<byte> for direct UTF-8 byte processing. Source-generation overloads accept a JsonTypeInfo<T> from a JsonSerializerContext, bypassing reflection entirely.
JsonSerializerOptions
A mutable configuration object that controls all aspects of serialization and deserialization behavior — property naming policy, null handling, number coercion, comment tolerance, custom converters, and more. Instances are frozen (immutable) after first use and are safe for concurrent reads once frozen. Always create one static readonly instance per configuration profile rather than constructing new options per call. The JsonSerializerOptions.Default static property returns a pre-frozen default instance. In .NET 8+, JsonSerializerOptions.Web provides a pre-configured instance suitable for REST APIs with camelCase naming and case-insensitive reading.
JsonNode
A mutable, DOM-based representation of JSON in System.Text.Json.Nodes. JsonNode.Parse(string) returns a JsonNode that is concretely a JsonObject (for JSON objects), JsonArray (for JSON arrays), or JsonValue (for JSON primitives). Properties are accessed via the [] indexer and values extracted with GetValue<T>(). Supports mutation — setting a property creates or overwrites it in-place. Use ToJsonString() to re-serialize the modified tree. JsonNode is suitable for dynamic JSON manipulation; for read-only parsing of large payloads, JsonDocument is more memory-efficient.
JsonConverter
An abstract base class (JsonConverter<T>) for implementing custom serialization and deserialization logic for a specific type. Subclasses implement Read(ref Utf8JsonReader, Type, JsonSerializerOptions) for deserialization and Write(Utf8JsonWriter, T, JsonSerializerOptions) for serialization. Converters operate at the Utf8JsonReader/Utf8JsonWriter level for maximum efficiency. Register converters globally via JsonSerializerOptions.Converters or per-property via [JsonConverter(typeof(MyConverter))]. In .NET 7+, [JsonPolymorphic] replaces the need for manual discriminated union converters for common cases.
source generation
A .NET Roslyn source generator feature in System.Text.Json that emits C# serialization code at compile time rather than inspecting types via reflection at runtime. Activated by creating a partial class that extends JsonSerializerContext and annotating it with [JsonSerializable(typeof(T))] for each type to support. The generator produces JsonTypeInfo<T> instances accessible via MyContext.Default.MyType. Required for Native AOT compatibility (Blazor WASM, .NET Native). Trimming-safe — enables the IL linker to remove unused code paths with confidence. Two modes: metadata (generates type info, minimal reflection) and serialization-optimized (generates full read/write code, zero reflection).
AOT compilation
Ahead-of-Time compilation converts .NET IL (intermediate language) to native machine code at build time rather than at runtime via the JIT (Just-in-Time) compiler. AOT-compiled .NET applications start faster, use less memory, and do not require the .NET runtime on the target machine. The trade-off is that AOT prohibits dynamic code generation and limits reflection — any type accessed via reflection must be explicitly declared as a root to prevent the trimmer from removing it. System.Text.Json source generation solves the serialization reflection problem by generating all serialization code statically. Used in Blazor WebAssembly (where the .NET runtime runs as WASM in the browser), .NET Native AOT (server and desktop), iOS, and Android via Xamarin/MAUI.

FAQ

How do I serialize a C# object to JSON with System.Text.Json?

Call JsonSerializer.Serialize(obj) to convert any C# object to a JSON string. For typed serialization, use JsonSerializer.Serialize<MyClass>(obj) — the generic overload enables source generation and is preferred for AOT. To serialize to a stream, use await JsonSerializer.SerializeAsync(stream, obj, cancellationToken) which writes UTF-8 bytes directly without intermediate string allocation. For fine-grained control, use Utf8JsonWriter directly. Common options: JsonSerializerOptions with PropertyNamingPolicy = JsonNamingPolicy.CamelCase converts PascalCase C# property names to camelCase JSON keys; WriteIndented = true adds whitespace for human-readable output. Cache the JsonSerializerOptions instance as a static readonly field — creating a new options object per call is expensive because it rebuilds internal caches. Use SerializeToUtf8Bytes() for byte arrays.

How do I deserialize JSON to a C# class?

Call JsonSerializer.Deserialize<MyClass>(jsonString) to parse a JSON string into a typed C# object. The return type is MyClass? (nullable) — always null-check the result. For streams, use await JsonSerializer.DeserializeAsync<MyClass>(stream) to read UTF-8 bytes without intermediate string allocation. JsonSerializer maps JSON keys to C# property names by exact name (case-insensitive by default) — if the JSON uses snake_case or camelCase, set PropertyNameCaseInsensitive = true on options, or use [JsonPropertyName("json_key")] on individual properties. When deserialization fails (malformed JSON, type mismatch), JsonSerializer throws JsonException — always catch it. For nullable reference types (NRTs enabled), missing JSON properties leave the C# property at its default value; use the required modifier (.NET 7+) or [JsonRequired] to enforce required fields.

What are the differences between System.Text.Json and Newtonsoft.Json?

System.Text.Json (STJ) is built into .NET 6+ and requires no NuGet package; Newtonsoft.Json (Json.NET) requires dotnet add package Newtonsoft.Json. STJ benchmarks at 2× the serialization throughput and uses 40% less memory than Newtonsoft.Json by processing UTF-8 bytes natively with Span<byte> rather than UTF-16 strings. Key features in Newtonsoft.Json NOT available in STJ: $type polymorphism for type-discriminated JSON, LINQ-to-JSON (JObject/JArray), loose DateTime string parsing (multiple formats), dynamic object support, and JsonSerializerSettings.Converters applying globally without rebuilding the options object. STJ advantages: AOT/source generation support for Blazor WASM and Native AOT, built-in Utf8JsonWriter for streaming output, and strict-by-default behavior that prevents silent data loss. Migration from Newtonsoft.Json requires replacing JsonConvert.SerializeObject with JsonSerializer.Serialize and JObject with JsonObject.

How do I configure JSON serialization options in C#?

Create a JsonSerializerOptions instance and set properties before first use — options are frozen after the first serialization call. Critical: create one static readonly instance and reuse it everywhere. Key options: PropertyNamingPolicy = JsonNamingPolicy.CamelCase converts PascalCase to camelCase; DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull omits null properties from output; AllowTrailingCommas = true and ReadCommentHandling = JsonCommentHandling.Skip make parsing more lenient; NumberHandling = JsonNumberHandling.AllowReadingFromString allows parsing numbers that arrive as JSON strings (common in legacy APIs); WriteIndented = true enables pretty-printing. For .NET 8+, use JsonSerializerOptions.Web (a pre-configured instance with CamelCase naming and case-insensitive reading) for API scenarios. JsonSerializerOptions is not thread-safe during construction but is safe for concurrent reads after it is frozen.

How do I handle dynamic JSON in C# without a class?

Use JsonNode.Parse(jsonString) to get a mutable DOM — the result is a JsonNode that is concretely a JsonObject, JsonArray, or JsonValue. Access properties with the [] indexer: node["name"]?.GetValue<string>() returns the string value or null if the key is absent. JsonNode supports mutation: set node["newKey"] = JsonValue.Create(42) to add or overwrite properties. For read-only access with zero allocation, use JsonDocument.Parse(jsonString) inside a using block — it returns a JsonDocument with a RootElement of type JsonElement. Access nested values with doc.RootElement.GetProperty("key").GetString(). JsonElement.TryGetProperty() is the safe version that returns false instead of throwing when a key is absent. To build JSON programmatically, use new JsonObject { ["key"] = "value" } syntax.

How do I implement a custom JSON converter in C#?

Create a class that extends JsonConverter<T> and implement Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) and Write(Utf8JsonWriter writer, T value, JsonSerializerOptions options). In Read, use reader.TokenType to check the current JSON token and reader.GetString()/GetInt32()/GetDateTime() to extract values. In Write, use writer.WriteStringValue()/WriteNumberValue(). Register globally in JsonSerializerOptions.Converters.Add(new MyConverter()), or per-property with [JsonConverter(typeof(MyConverter))]. Common use cases: converting DateTime to Unix epoch integers, handling discriminated unions. Never call JsonSerializer.Deserialize inside a converter for the same type — it causes infinite recursion; use reader methods or JsonDocument.ParseValue(ref reader) instead.

How do I use JSON source generation in .NET?

Create a partial class that extends JsonSerializerContext and annotate it with [JsonSerializable(typeof(MyClass))] for each type you want to support. The source generator emits serialization metadata at compile time — no reflection at runtime. Use the generated context: JsonSerializer.Serialize(obj, MyContext.Default.MyClass) or JsonSerializer.Deserialize(json, MyContext.Default.MyClass). Stack multiple [JsonSerializable] attributes for all types, including collection types ([JsonSerializable(typeof(List<MyClass>))] separately). Configure options via [JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]. Enable GenerationMode = JsonSourceGenerationMode.Serialization for zero-reflection code generation required for Native AOT. The trimming analyzer warns about types not covered by the context — add them to eliminate warnings and ensure AOT safety.

How do I configure JSON in ASP.NET Core?

In Program.cs, call builder.Services.ConfigureHttpJsonOptions(options => { ... }) for minimal APIs, or builder.Services.AddControllers().AddJsonOptions(options => { ... }) for MVC controllers. To add a custom converter globally, call options.SerializerOptions.Converters.Add(new MyConverter()). Minimal APIs auto-serialize returned typed objects and auto-deserialize JSON request bodies — no manual JsonSerializer calls needed. Use Results.Json(obj, options) to override options for a specific endpoint, or TypedResults.Json(obj, MyContext.Default) for AOT-safe source-generated responses. To switch to Newtonsoft.Json, install Microsoft.AspNetCore.Mvc.NewtonsoftJson and call AddNewtonsoftJson() — discouraged for new projects.

Further reading and primary sources