1. Introduction to JSON Serialization in .NET
JSON has become the lingua franca of modern software systems. Whether you’re building APIs, consuming web services, or storing configuration data, chances are you’re dealing with JSON. In the .NET ecosystem, handling JSON has become increasingly streamlined—thanks to the evolution of System.Text.Json
, which provides a high-performance, low-allocation alternative to older libraries like Newtonsoft.Json.
At a glance, serialization (converting an object into JSON) and deserialization (converting JSON back into an object) seem straightforward. For many standard use cases, using JsonSerializer.Serialize()
and JsonSerializer.Deserialize<T>()
will get the job done with minimal effort. But real-world applications often introduce complexity: custom data formats, performance constraints, conditional fields, or models that don’t match the exact JSON schema you’re working with.
That’s where lower-level tools like Utf8JsonWriter
and Utf8JsonReader
come in. They give you precise control over the serialization process—letting you manually write and read JSON tokens in a highly optimized way. Instead of relying on reflection and automatic mapping, you define exactly how data flows to and from JSON, one token at a time.
This tutorial is for developers who are ready to go beyond the default serializer and need fine-tuned control over their JSON logic. Whether you’re building a high-throughput system, interfacing with third-party APIs with non-standard formats, or just want to squeeze every drop of performance out of your app, understanding Utf8JsonWriter
and Utf8JsonReader
can be a game-changer.
In the sections that follow, we’ll break down how to use these tools from the ground up, starting with how to write basic JSON manually, and moving toward full custom serialization and deserialization logic for complex .NET objects.
2. Overview of Utf8JsonWriter and Utf8JsonReader (≈250 words)
When working with JSON in .NET, the System.Text.Json
namespace gives you more than just high-level serialization methods—it also includes two powerful low-level APIs: Utf8JsonWriter
and Utf8JsonReader
. These are designed for developers who need full control over JSON input and output, especially in performance-critical or non-standard scenarios.
Utf8JsonWriter
Utf8JsonWriter
is a forward-only, high-performance writer that emits JSON directly to a Stream
or IBufferWriter<byte>
. It writes UTF-8 encoded text, which makes it particularly efficient for web applications and microservices where every millisecond and byte counts. You use it by explicitly writing each JSON token—start object, property name, value, array start, and so on. This approach avoids reflection, unnecessary allocations, and the overhead of generic serialization.
Utf8JsonReader
On the flip side, Utf8JsonReader
is a forward-only reader that parses UTF-8 JSON data into a stream of tokens. Instead of deserializing an entire object automatically, you manually navigate through the tokens and extract values as needed. This gives you unmatched flexibility—for example, you can skip irrelevant fields, handle unexpected data gracefully, or map flat JSON into deeply nested models.
These two classes are intentionally low-level and minimal by design. They don’t manage state for you, nor do they build object graphs. But that’s exactly what makes them so powerful—they give you raw access to the data and trust you to manage the logic.
3. Setting Up the Development Environment (≈150 words)
Before diving into code, let’s set up a clean development environment to experiment with Utf8JsonWriter
and Utf8JsonReader
.
Prerequisites
Make sure you have the following installed:
- .NET 6.0 or later – These APIs are available in
System.Text.Json
, which ships with .NET Core 3.0 and above, but .NET 6+ is recommended for improved performance and features. - Visual Studio 2022, Rider, or VS Code – Any modern IDE that supports C# will work.
Creating the Project
Open your terminal or IDE and create a new console application:
dotnet new console -n JsonSerializationDemo
cd JsonSerializationDemo
Code language: JavaScript (javascript)
No additional NuGet packages are required since System.Text.Json
is part of the base class library.
Optional Setup
To make testing easier, you can add the following NuGet package for debugging output:
dotnet add package Microsoft.Extensions.Logging.Console
Code language: CSS (css)
This setup gives you a clean slate to focus on hands-on examples. Next, we’ll get into the basics of writing JSON manually using Utf8JsonWriter
.
4. Understanding Utf8JsonWriter
At first glance, manually writing JSON might sound tedious—but Utf8JsonWriter
makes it surprisingly straightforward. It’s a low-level, high-performance API that lets you build JSON directly, token by token, with full control over structure, formatting, and data types.
How It Works
Utf8JsonWriter
writes JSON to an IBufferWriter<byte>
, commonly backed by a MemoryStream
. It’s forward-only and writes raw UTF-8 encoded bytes, which keeps it fast and efficient. You don’t deal with strings unless you want to, and there’s no reflection overhead.
Basic Setup
Here’s a minimal example of using Utf8JsonWriter
to serialize a simple object:
using System;
using System.IO;
using System.Text.Json;
var stream = new MemoryStream();
var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
writer.WriteStartObject();
writer.WriteString("name", "Alice");
writer.WriteNumber("age", 30);
writer.WriteBoolean("isActive", true);
writer.WriteEndObject();
writer.Flush();
string json = System.Text.Encoding.UTF8.GetString(stream.ToArray());
Console.WriteLine(json);
Code language: JavaScript (javascript)
Output:
{
"name": "Alice",
"age": 30,
"isActive": true
}
Code language: JSON / JSON with Comments (json)
This example shows how each field is explicitly written using a method tailored to its type.
Writing Arrays
You can also write arrays easily:
writer.WriteStartArray("tags");
writer.WriteStringValue("developer");
writer.WriteStringValue("blogger");
writer.WriteEndArray();
Code language: CSS (css)
This would result in:
"tags": ["developer", "blogger"]
Code language: JavaScript (javascript)
Nesting Objects
Nested structures are handled by chaining calls:
writer.WriteStartObject("address");
writer.WriteString("city", "Seattle");
writer.WriteString("zip", "98101");
writer.WriteEndObject();
Code language: JavaScript (javascript)
Which produces:
"address": {
"city": "Seattle",
"zip": "98101"
}
Code language: JavaScript (javascript)
Key Takeaways
- Use the correct
WriteX
methods based on data type. - Always match
WriteStartObject
withWriteEndObject
, andWriteStartArray
withWriteEndArray
. - Call
Flush()
to ensure all data is written to the output stream.
5. Understanding Utf8JsonReader
Just as Utf8JsonWriter
gives you precise control over writing JSON, Utf8JsonReader
offers a fast and memory-efficient way to read and parse JSON. It’s a forward-only, read-only parser that processes UTF-8 encoded text as a stream of tokens. This design makes it ideal for high-performance scenarios where allocating full object graphs isn’t necessary—or even possible.
How It Works
Utf8JsonReader
reads a ReadOnlySpan<byte>
or ReadOnlySequence<byte>
containing JSON text. It doesn’t create objects or deserialize types for you—instead, it exposes a sequence of tokens (e.g., StartObject
, PropertyName
, String
, EndObject
) that you can read and act on manually.
Basic Usage Example
Let’s say we want to read the following JSON:
{
"name": "Alice",
"age": 30,
"isActive": true
}
Code language: JSON / JSON with Comments (json)
Here’s how we could parse it:
using System;
using System.Text;
using System.Text.Json;
var json = "{\"name\":\"Alice\",\"age\":30,\"isActive\":true}";
var jsonBytes = Encoding.UTF8.GetBytes(json);
var reader = new Utf8JsonReader(jsonBytes);
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.PropertyName)
{
string propertyName = reader.GetString();
reader.Read(); // Move to the value
switch (propertyName)
{
case "name":
string name = reader.GetString();
Console.WriteLine($"Name: {name}");
break;
case "age":
int age = reader.GetInt32();
Console.WriteLine($"Age: {age}");
break;
case "isActive":
bool isActive = reader.GetBoolean();
Console.WriteLine($"Active: {isActive}");
break;
}
}
}
Code language: JavaScript (javascript)
Token Types
Utf8JsonReader
exposes many token types:
StartObject
,EndObject
StartArray
,EndArray
PropertyName
String
,Number
,Boolean
,Null
You must carefully manage state as you read—there’s no “back” button. Think of it like a JSON cursor that always moves forward.
Advantages
- Minimal allocations
- Fast, especially for large payloads
- Full control over structure and validation
6. Implementing Custom Serialization Logic
Now that you’ve got a handle on Utf8JsonWriter
, it’s time to go beyond basic examples and look at how to serialize more complex objects manually. Custom serialization is particularly useful when you need to:
- Rename or omit certain fields
- Reorder properties for readability or compatibility
- Handle polymorphic data
- Serialize non-standard types (like
DateTimeOffset
, enums as strings, etc.)
Let’s walk through a real-world scenario.
Example Model
Suppose we have a class like this:
public class Product
{
public int Id { get; set; }
public string Name { get; set; }
public decimal Price { get; set; }
public string? Description { get; set; }
public List<string> Tags { get; set; } = new();
}
Code language: JavaScript (javascript)
Custom Serialization Function
Here’s how you could write a method to serialize this manually using Utf8JsonWriter
:
public static void WriteProduct(Utf8JsonWriter writer, Product product)
{
writer.WriteStartObject();
writer.WriteNumber("id", product.Id);
writer.WriteString("name", product.Name);
writer.WriteNumber("price", product.Price);
if (!string.IsNullOrWhiteSpace(product.Description))
{
writer.WriteString("description", product.Description);
}
writer.WriteStartArray("tags");
foreach (var tag in product.Tags)
{
writer.WriteStringValue(tag);
}
writer.WriteEndArray();
writer.WriteEndObject();
}
Code language: PHP (php)
Customizing Output
With manual control, you can choose:
- To omit null or empty properties (
Description
in this case) - To write arrays even when they’re empty, or skip them entirely
- To include metadata like timestamps or version fields dynamically
Want to serialize enums as strings instead of numbers? Easy:
writer.WriteString("status", product.Status.ToString());
Code language: CSS (css)
You can even nest objects:
writer.WriteStartObject("manufacturer");
writer.WriteString("name", product.Manufacturer.Name);
writer.WriteString("country", product.Manufacturer.Country);
writer.WriteEndObject();
Code language: CSS (css)
Formatting Tips
You can optionally configure the writer for indentation if you want human-readable output:
var options = new JsonWriterOptions
{
Indented = true
};
var writer = new Utf8JsonWriter(stream, options);
Code language: JavaScript (javascript)
Why This Matters
Manual serialization allows you to meet third-party API contracts that don’t align with your model, reduce payload size by excluding default or redundant values, and ensure your output is exactly what your system or client expects.
7. Implementing Custom Deserialization Logic
While writing JSON manually gives you control over output, custom deserialization is where things really get interesting—and occasionally tricky. With Utf8JsonReader
, you handle incoming JSON token by token, building objects from the ground up. This is powerful when dealing with irregular schemas, partial updates, or performance-critical workloads where object instantiation needs to be lean and precise.
Let’s look at how to deserialize the Product
class we used earlier.
Sample JSON
{
"id": 101,
"name": "Laptop",
"price": 1299.99,
"description": "A high-end ultrabook",
"tags": ["electronics", "portable"]
}
Code language: JSON / JSON with Comments (json)
Custom Deserialization Method
public static Product ReadProduct(ref Utf8JsonReader reader)
{
var product = new Product();
if (reader.TokenType != JsonTokenType.StartObject)
throw new JsonException("Expected StartObject token");
while (reader.Read())
{
if (reader.TokenType == JsonTokenType.EndObject)
return product;
if (reader.TokenType == JsonTokenType.PropertyName)
{
string propertyName = reader.GetString();
reader.Read(); // Move to value
switch (propertyName)
{
case "id":
product.Id = reader.GetInt32();
break;
case "name":
product.Name = reader.GetString() ?? "";
break;
case "price":
product.Price = reader.GetDecimal();
break;
case "description":
product.Description = reader.TokenType == JsonTokenType.Null ? null : reader.GetString();
break;
case "tags":
if (reader.TokenType == JsonTokenType.StartArray)
{
while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
{
product.Tags.Add(reader.GetString() ?? "");
}
}
break;
default:
reader.Skip(); // Safely ignore unknown fields
break;
}
}
}
throw new JsonException("Incomplete JSON object");
}
Code language: PHP (php)
Key Concepts
- Ref Parameter: The reader must be passed by reference (
ref Utf8JsonReader
) because it’s a struct. - Validation: You’re responsible for checking token types to ensure the JSON structure is valid.
- Fallbacks: If a value is missing or null, provide sensible defaults.
- Skipping Unknowns: Use
reader.Skip()
to move past unexpected properties gracefully.
Benefits of Custom Deserialization
- You can handle flexible or evolving JSON formats without breaking
- You can selectively read only relevant fields, reducing overhead
- You can layer validation or transformation into the parsing logic
This is especially useful when consuming third-party APIs where field names may differ, optional properties may appear inconsistently, or nested structures require conditional logic.
8. Putting It All Together: Full Serialization/Deserialization Example
Now that you’ve learned how to manually write and read JSON using Utf8JsonWriter
and Utf8JsonReader
, let’s walk through a complete example to reinforce everything.
We’ll use the Product
class again, perform both serialization and deserialization, and show how the two processes work seamlessly together.
Full Round-Trip Example
using System;
using System.IO;
using System.Text;
using System.Text.Json;
using System.Collections.Generic;
// Product class defined earlier...
// Serialize
var product = new Product
{
Id = 42,
Name = "Mechanical Keyboard",
Price = 109.95m,
Description = "RGB backlit with hot-swappable switches",
Tags = new List<string> { "electronics", "input-device" }
};
using var stream = new MemoryStream();
var writer = new Utf8JsonWriter(stream, new JsonWriterOptions { Indented = true });
WriteProduct(writer, product);
writer.Flush();
string json = Encoding.UTF8.GetString(stream.ToArray());
Console.WriteLine("Serialized JSON:");
Console.WriteLine(json);
// Deserialize
var jsonBytes = Encoding.UTF8.GetBytes(json);
var reader = new Utf8JsonReader(jsonBytes);
Product parsedProduct = ReadProduct(ref reader);
Console.WriteLine("\nDeserialized Product:");
Console.WriteLine($"ID: {parsedProduct.Id}");
Console.WriteLine($"Name: {parsedProduct.Name}");
Console.WriteLine($"Price: {parsedProduct.Price:C}");
Console.WriteLine($"Tags: {string.Join(", ", parsedProduct.Tags)}");
Code language: PHP (php)
Why This Matters
This round-trip shows how you can:
- Fully control what gets serialized and how it’s formatted
- Build an object safely and predictably from incoming JSON
- Avoid unnecessary overhead from reflection or third-party libraries
This approach is perfect for performance-critical systems, custom APIs, or interoperable services with strict formatting needs.
9. Error Handling and Edge Cases
When working with JSON manually, you need to account for the messiness of real-world data. Unlike JsonSerializer
, which throws structured exceptions, Utf8JsonReader
and Utf8JsonWriter
leave error handling up to you. That’s both a responsibility and a benefit—you get to decide how strict or flexible your logic should be.
Common Pitfalls
- Unexpected Token Types: Always check
TokenType
before callingGetString()
,GetInt32()
, etc. Accessing the wrong method will throw an exception. - Missing or Extra Fields: If a required property is missing, decide whether to use a default, throw an error, or log and continue.
- Null Values: Handle
JsonTokenType.Null
explicitly when reading strings or nested objects. - Malformed JSON: Wrap deserialization in a try-catch block to detect corruption or incomplete input.
Defensive Patterns
if (!reader.TryGetInt32(out int value))
{
// fallback or log
}
Code language: JavaScript (javascript)
Being cautious in your parsing logic ensures your application won’t crash on malformed data or future changes to JSON shape.
10. Performance Considerations
One of the key reasons to use Utf8JsonWriter
and Utf8JsonReader
is performance. Compared to high-level serializers like JsonSerializer
, these low-level APIs offer:
- Fewer allocations: No boxing, reflection, or object instantiation unless you explicitly choose to.
- Stream-based processing: Ideal for large payloads or streaming scenarios.
- Precise control: Read only the data you need; skip the rest.
In benchmarks, manual JSON parsing using these APIs often outperforms traditional serializers—especially when you’re dealing with large volumes of data, partial updates, or constrained environments like microservices.
However, with great power comes great responsibility. You trade off convenience and readability for control and speed. That makes these APIs better suited for hot paths (e.g. request/response processing, logging pipelines) rather than general-purpose data handling.
Pro Tip
To further reduce overhead, reuse buffers and streams when possible, and avoid unnecessary conversions (like UTF-16 string decoding) unless needed.
Here is Section 11: Conclusion and Next Steps (≈100 words):
11. Conclusion and Next Steps
You’ve now seen how Utf8JsonWriter
and Utf8JsonReader
give you full control over JSON serialization and deserialization in .NET. From writing primitive values to handling complex object graphs and navigating JSON token streams, you’ve learned how to bypass the limitations of automatic serialization with a more hands-on, performant approach.
These tools are perfect for high-throughput APIs, custom integrations, and scenarios where efficiency and structure control are critical.