JSON is the lingua franca of modern APIs and configuration files, and System.Text.Json is the built-in library .NET provides to convert between JSON text and C# objects. This lesson covers what the serializer does, how to control its output, how to read and write JSON without giving up on performance, and the parts of the API used in e-commerce code: serializing a Product, deserializing an order coming back from a checkout API, streaming a large catalog file, and parsing dynamic JSON when the shape isn't known up front.
System.Text.Json ExistsBefore .NET Core 3.0, most C# code used Newtonsoft.Json (also called Json.NET) for JSON. It is mature, feature-rich, and still very common in production. Microsoft shipped System.Text.Json as a built-in alternative for three concrete reasons.
First, it ships with the runtime. There is no NuGet package to install, no version-mismatch issues, no transitive dependency conflicts. Any modern .NET project can write using System.Text.Json; and be done.
Second, it was designed around UTF-8 from the start. JSON on the wire is almost always UTF-8 bytes. Newtonsoft converts those bytes to a .NET string (UTF-16 internally) before parsing, which doubles memory use and adds a transcoding pass. System.Text.Json works directly on the UTF-8 byte buffer using the Utf8JsonReader and Utf8JsonWriter types, which is faster and allocates far less. For a service deserializing thousands of API responses per second, the difference shows up in CPU graphs and GC pause times.
Third, it was designed to be allocation-aware. The reader is a ref struct that walks the buffer without allocating intermediate objects. The writer fills a buffer the caller owns. The high-level JsonSerializer API hides that machinery, but it inherits the same low-allocation foundation.
The trade-off, especially in the early versions, was features. System.Text.Json started smaller. Polymorphism, populating an existing object instead of constructing a new one, custom contract resolvers, JObject-style mutable DOM, and many other Newtonsoft features arrived later. As of .NET 8, most everyday needs are covered, but a few advanced scenarios still favor Newtonsoft. The table below sketches the practical comparison.
| Concern | System.Text.Json | Newtonsoft.Json |
|---|---|---|
| Distribution | Built into .NET | NuGet package |
| Default speed | Faster on most workloads | Slower, more allocations |
| UTF-8 first | Yes, native byte APIs | UTF-16 strings, then transcoded |
| Polymorphism | Built in since .NET 7 ([JsonDerivedType]) | Built in for years |
| Mutable DOM | JsonNode/JsonObject (since .NET 6) | JObject/JArray (always) |
| Source generation | Yes ([JsonSerializable]) | No |
| AOT / trimming friendly | Yes, with source generators | Limited |
| Default case sensitivity | Case-sensitive | Case-insensitive |
LINQ to JSON style queries | Limited | Rich |
For new code on .NET 6 and later, System.Text.Json is the default choice unless a specific feature pushes the decision to Newtonsoft.
The diagram shows the round trip the serializer manages. Going left to right is serialization (object to JSON), going right to left is deserialization (JSON back to object). Both directions go through the same JsonSerializer static class, just different methods.
The two most-used methods live on the static JsonSerializer class. Serialize<T> converts an object to a JSON string. Deserialize<T> does the reverse.
The output is one line with no spaces, which is the right format on the wire because every byte costs network and CPU. Property names match the C# names exactly, including capitalization. Public properties with getters are included by default; fields and private members are skipped unless opted in.
Going the other way uses Deserialize<T>:
The return type is Product?. The serializer can return null if the JSON is literally the token null, so the API surface is honest about that possibility. The ! after product is the null-forgiving operator, which tells the compiler "this is fine here." Production code would check for null explicitly.
The same methods have non-generic overloads that take a Type argument, which is useful when the type is only known at runtime (for example, when a router dispatches to a handler chosen by URL).
The generic version is preferred whenever the type is known at compile time. It returns the precise type and lets the compiler help. The Type overload exists for genuine reflection scenarios.
JsonSerializer.Serialize and Deserialize build a metadata cache for each type the first time they see it. Subsequent calls reuse that cache, so the first call is meaningfully slower than the rest. For startup-sensitive code, source generation (covered later in this lesson) eliminates that warm-up.
Calling Serialize and Deserialize with strings is fine for small objects. For anything larger, like a multi-megabyte product catalog, an HTTP response body, or a file, the string-based API allocates the entire payload twice: once as bytes and once as a UTF-16 string. The stream-based API works directly on bytes and supports async, which keeps the calling thread free during the I/O wait.
SerializeAsync writes UTF-8 bytes straight into the stream as it walks the object graph. It writes incrementally rather than building a string first. For a 100 MB catalog, that's the difference between a working program and an OutOfMemoryException.
Reading back is symmetric:
DeserializeAsync<T> reads from the stream incrementally, parsing tokens as bytes arrive. Use it for HTTP response bodies, large files, and any scenario where the JSON might be too large to hold as a string in memory.
Serialize/Deserialize (string overloads) allocate the entire payload as a UTF-16 string. For payloads above a few KB or in hot paths, prefer SerializeAsync/DeserializeAsync with a stream. The async stream APIs allocate proportional to the working buffer, not the payload size.
The async stream APIs are also the standard way to consume a streaming JSON array element by element, covered later under IAsyncEnumerable<T> support.
JsonSerializerOptionsThe default settings are sensible but not always what is needed. Property names in JSON conventions are usually camelCase, not PascalCase. APIs often send fields the C# class doesn't model, and these should be ignored rather than throw. Pretty-printed output is helpful for human-readable config files. All of this is configured through a JsonSerializerOptions instance.
WriteIndented = true adds line breaks and two-space indentation. PropertyNamingPolicy = JsonNamingPolicy.CamelCase lower-cases the first letter of each property name during both serialization and deserialization. Since .NET 8 there are two more built-in policies, JsonNamingPolicy.SnakeCaseLower and JsonNamingPolicy.KebabCaseLower, which produce in_stock and in-stock respectively.
The next-most-important option is PropertyNameCaseInsensitive. By default, Deserialize is case-sensitive, which differs from Newtonsoft's behavior.
In strict mode, the JSON keys id, name, etc. don't match the C# property names Id, Name, so the values are dropped without warning and the properties stay at their defaults. With PropertyNameCaseInsensitive = true, the matching ignores case. The lenient mode fits most APIs, but there's a small lookup cost per property because the matching can't use a direct hash-based lookup. For very hot paths, configuring PropertyNamingPolicy so the names match exactly is faster.
A few more options:
| Option | Effect |
|---|---|
DefaultIgnoreCondition | Skip properties when they match a condition (e.g., WhenWritingNull, WhenWritingDefault) |
IncludeFields | Serialize public fields, not just properties |
NumberHandling | Allow numbers written as JSON strings ("42" becomes 42), or write them as strings on output |
Converters | Register custom JsonConverter<T> instances |
ReferenceHandler | Handle object cycles by emitting $id/$ref markers (use ReferenceHandler.Preserve) |
Encoder | Control which characters are escaped in output (default escapes more than strictly required) |
MaxDepth | Maximum object nesting allowed (default 64), guards against malicious deeply-nested input |
AllowTrailingCommas | Accept JSON with a trailing comma after the last element of an array or object |
ReadCommentHandling | Allow // ... comments in input (useful for config files) |
DefaultIgnoreCondition is the standard way to keep payloads small.
The Description property is omitted because its value is null. WhenWritingDefault would also omit InStock because false is the default for bool, which may or may not match the intent depending on whether "not set" must be distinguished from "explicitly false."
Construct one JsonSerializerOptions per configuration and reuse it. The serializer caches type metadata per JsonSerializerOptions instance, so creating a new JsonSerializerOptions on every call repeatedly invalidates the cache and can slow throughput by an order of magnitude. The standard pattern is a static readonly JsonSerializerOptions per configuration.
Options apply to every property the serializer touches. Attributes apply to one property at a time, and they win over options when both could apply. They live in System.Text.Json.Serialization.
[JsonPropertyName] overrides the JSON name for one property, used when the wire format doesn't match what a naming policy would produce.
product_name overrides the camelCase policy, while the other properties still follow the policy. This pattern is common when the C# class has to talk to an existing API whose names don't match its own conventions.
[JsonIgnore] removes a property from serialization entirely, used to keep secrets and internal flags out of the wire format.
The variant [JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)] skips the property only when the value is null, the same idea as DefaultIgnoreCondition but scoped to one property. JsonIgnoreCondition.WhenWritingDefault and Always are also available.
[JsonInclude] is the opposite of [JsonIgnore]: it forces a non-public member or a property without a public setter to be serialized.
Without [JsonInclude], Total would still be serialized (it has a getter), but it would not be deserialized because it has no public setter. [JsonInclude] allows the deserializer to call the private setter.
[JsonConstructor] tells the serializer which constructor to use when a class has more than one. The serializer matches constructor parameters to JSON property names, case-insensitively if PropertyNameCaseInsensitive is on, otherwise by exact match (or by the names from the naming policy).
This pattern is the standard form of immutable DTOs: init-only properties or constructor-only assignment, no setters, and JsonConstructor to bridge JSON into the constructor.
[JsonExtensionData] collects properties the JSON contains but the class doesn't model into a Dictionary<string, JsonElement> or Dictionary<string, object>. It avoids losing data when an API adds fields the class hasn't been updated for yet.
A round trip preserves the extras, which means a service can deserialize, modify a few known fields, and reserialize without dropping unknown fields. That property keeps API gateways working when an upstream service adds new data.
[JsonNumberHandling] per property and [JsonPropertyOrder] for output ordering round out the most-used attributes. JsonNumberHandling.AllowReadingFromString accepts "42" for an int field (some APIs send numbers as strings to dodge JavaScript's integer-precision issues). JsonPropertyOrder controls the order properties appear in serialized output, lower numbers first, with unspecified properties keeping their declaration order after the ordered ones.
AllowReadingFromString accepts the string-quoted numbers in the input. JsonPropertyOrder sorts the properties on serialization so output emits them in the desired order, regardless of declaration order. (This snippet uses Deserialize, so the order attribute doesn't show up in the output, but the same class would serialize in Id, Quantity, Price order.)
Sometimes the built-in serializer doesn't know how to handle a type the way the wire format demands. A common example is a date field that uses a non-standard format, like yyyyMMdd instead of ISO 8601. A custom converter handles the type-specific read and write logic.
A converter inherits from JsonConverter<T> and implements two methods. Read parses JSON into a T. Write produces JSON from a T.
To use the converter, register it on options or attach it to a property with [JsonConverter].
The converter applies to every DateTime written or read with these options. To scope the converter to a single property, use [JsonConverter(typeof(CompactDateConverter))] on that property and the global default still applies to other DateTime fields.
Converters can do more than format conversion. They can flatten an object into a single JSON value, expand a single value into an object graph, or implement polymorphic discrimination by hand for cases the built-in support doesn't cover. They are the escape hatch when the built-in machinery isn't enough.
Converters are called once per value. They run inside tight serialization loops, so heavy work, allocations, or culture-sensitive parsing on the hot path adds up. The example above uses CultureInfo.InvariantCulture to avoid the overhead and inconsistencies of the current culture, which is the standard default for any wire format.
JsonDocument and JsonElementSometimes there is no DTO. The JSON arrives, one or two fields are needed, and constructing a class for the entire shape is wasted work. JsonDocument parses an entire JSON payload into a tree of JsonElement values navigable by name and index.
JsonDocument is IDisposable because it pools its internal buffers. Always use a using statement, otherwise pooled memory hangs around until the GC runs. JsonElement is a readonly struct view into the document; it's lightweight to copy.
The element API is strongly typed. GetString, GetInt32, GetDecimal, GetBoolean, EnumerateArray, EnumerateObject, and TryGet* variants provide both type safety and explicit handling of missing or wrong-typed values.
JsonDocument is read-only. To build or modify JSON dynamically, the sibling API JsonNode (with JsonObject and JsonArray) provides a mutable DOM.
Use JsonNode when the shape is dynamic, when merging two JSON documents, or when proxying a payload through with small modifications. It allocates more than JsonDocument/JsonElement, so for read-only inspection of a payload, prefer the document API.
JsonDocument.Parse allocates pooled buffers proportional to the input size. For very large payloads, JsonDocument.Parse still loads the whole thing in memory. To stream-parse large arrays element by element, use JsonSerializer.DeserializeAsyncEnumerable<T> (covered briefly below), which parses incrementally without holding the whole payload at once.
For the streaming case:
This pattern is essential for very large arrays in NDJSON-style or single-array-of-objects formats. The stream is consumed element by element, and the program only holds one Order at a time in memory.
The default JsonSerializer uses reflection to discover properties at runtime. Reflection is fast on .NET, but it has costs: a warm-up pass when each type is first seen, runtime IL emission that doesn't survive AOT (Ahead-Of-Time) compilation, and a working set that includes the entire reflection metadata graph. For startup-sensitive apps, trimmed apps (where unused IL is removed), or AOT-compiled apps (Native AOT, common in containers and serverless), source generators are the better path.
A source generator runs at compile time, inspects types marked with [JsonSerializable], and generates the read/write code as plain C# the compiler then builds. There is no reflection at runtime, the trimmer can see exactly which types are used, and AOT works without warnings.
The partial class ProductContext : JsonSerializerContext marker tells the source generator to produce the implementation. The generated code exposes ProductContext.Default.Product, a JsonTypeInfo<Product> the serializer can use without reflection. Multiple [JsonSerializable] attributes on the context class register multiple types.
To configure the generator, add a [JsonSourceGenerationOptions] attribute to the context.
The generator now bakes camelCase and indentation into the generated code, removing the need to construct JsonSerializerOptions at runtime.
| Feature | Reflection mode (default) | Source-generation mode |
|---|---|---|
| First-call warm-up | Yes (metadata cache) | No |
| Works under Native AOT | No | Yes |
| Works with full trimming | Limited (annotations needed) | Yes |
| Per-call overhead | Small | Smaller |
| Code size | Smaller | Slightly larger (generated code) |
| Configuration | At runtime via JsonSerializerOptions | At compile time via attributes |
For a typical web service running on regular JIT, the difference is small. For a Native AOT-compiled API that needs to start in 50 ms and stay small, source generation is the only realistic option.
A handful of behaviors cause regular debugging issues. Knowing them up front saves time.
Case sensitivity is on by default. A JSON payload with id won't deserialize into a property named Id unless PropertyNameCaseInsensitive = true is set or a naming policy aligns the names. The deserializer doesn't throw on a mismatch, it just leaves the property at its default value, which is the worst kind of bug because it looks like the data was missing.
Properties without setters are skipped on deserialize. A read-only property (public int Id { get; }) is fine to serialize but won't be populated on deserialize unless it is a constructor parameter (or has init and is matched in the constructor). The serializer needs a way to set the value. Adding [JsonInclude] to a property with a non-public setter is the usual fix.
Fields aren't serialized by default. Only public properties are. To include public fields, set JsonSerializerOptions.IncludeFields = true or apply [JsonInclude] per field. The C# convention is to use properties anyway, so this is mainly a gotcha for anyone moving from frameworks that serialize fields.
Polymorphism needs explicit opt-in. If a base class has multiple derived types and a property is declared as the base type, the serializer writes only the base type's properties unless polymorphism is configured. Since .NET 7, the standard way is [JsonDerivedType] on the base.
The $type discriminator is written first, and on deserialize the serializer reads it before constructing the matching derived type. Without the [JsonDerivedType] attributes, only Id, Name, and Price would be written, and the type information would be lost.
Cycles cause a stack overflow by default. A graph where Order references Customer which references List<Order> (the customer's order history) loops forever during serialization. Set ReferenceHandler = ReferenceHandler.Preserve to emit $id and $ref markers, or ReferenceHandler = ReferenceHandler.IgnoreCycles to drop the back-reference. Most DTO designs sidestep this by keeping graphs flat.
`decimal` and very large `long` precision differs from JavaScript. JSON numbers are double-precision floats by default, which means a long value above 2^53 may round-trip incorrectly through code that uses double. System.Text.Json reads numbers into the target C# type directly, so a long field reads as a 64-bit integer with full precision. The cross-language pitfall comes when a JavaScript client parses the same value as a Number. The workaround is [JsonNumberHandling(JsonNumberHandling.WriteAsString)] to send the number as a quoted string, which preserves precision through any consumer.
Default `DateTime` parsing is strict ISO 8601. Anything else needs a custom converter. The same applies to DateTimeOffset, TimeSpan, and Guid. The pattern in the converter section above is the standard remedy.
A small example tying the pieces together. A web service serializes orders to a file, and another process reads them back.
A few patterns appear together here. JsonOptions is static readonly so the serializer cache isn't invalidated. The records use positional syntax with no setters, and the serializer matches constructor parameters by name (case-insensitively, due to the option). The async stream APIs handle the I/O without buffering the entire JSON as a string. The output is human-readable for the file format and would be one line for an HTTP response by flipping WriteIndented to false.
The same options work for an HTTP handler that returns JSON, a background job that reads a config file, and a worker that consumes a message off a queue. The serializer operates only on the bytes, regardless of source or sink.
9 quizzes