Why Serialization Matters
Serialization converts objects into formats you can store or transmit. You need it for saving data to files, sending objects over networks, caching, and communicating between systems. Without serialization, your in-memory objects can't leave your application's process.
Modern .NET applications primarily use JSON serialization through System.Text.Json. This built-in library offers excellent performance and security defaults. It replaced the older Newtonsoft.Json as the standard choice for new projects.
You'll learn how to serialize and deserialize objects, customize the output with attributes, create custom converters for complex types, and handle common scenarios like dates, enums, and polymorphic types.
Getting Started with System.Text.Json
JsonSerializer provides static methods for converting between objects and JSON. Serialize turns objects into JSON strings, while Deserialize converts JSON back into objects. The API is straightforward for simple scenarios and supports async operations for streams.
System.Text.Json uses source generators in .NET 6+ for ahead-of-time compilation. This improves performance and supports trimming and native AOT scenarios. For most cases, you won't need to think about source generators.
Here's how basic serialization and deserialization work:
using System;
using System.Text.Json;
var person = new Person
{
Id = 1,
FirstName = "John",
LastName = "Doe",
Email = "john.doe@example.com",
BirthDate = new DateTime(1990, 5, 15),
IsActive = true
};
// Serialize to JSON string
string json = JsonSerializer.Serialize(person);
Console.WriteLine("Serialized:");
Console.WriteLine(json);
// Serialize with formatting
string jsonFormatted = JsonSerializer.Serialize(person, new JsonSerializerOptions
{
WriteIndented = true
});
Console.WriteLine("\nFormatted:");
Console.WriteLine(jsonFormatted);
// Deserialize back to object
Person restored = JsonSerializer.Deserialize<Person>(json);
Console.WriteLine($"\nDeserialized: {restored.FirstName} {restored.LastName}");
// Working with collections
var people = new List<Person>
{
person,
new Person { Id = 2, FirstName = "Jane", LastName = "Smith", Email = "jane@example.com" }
};
string peopleJson = JsonSerializer.Serialize(people, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine("\nCollection:");
Console.WriteLine(peopleJson);
var restoredPeople = JsonSerializer.Deserialize<List<Person>>(peopleJson);
Console.WriteLine($"Restored {restoredPeople.Count} people");
class Person
{
public int Id { get; set; }
public string FirstName { get; set; }
public string LastName { get; set; }
public string Email { get; set; }
public DateTime BirthDate { get; set; }
public bool IsActive { get; set; }
}
By default, JsonSerializer serializes all public properties and respects property names as written. WriteIndented adds formatting for readability. Deserialization requires a parameterless constructor and public property setters. If types don't match exactly, JsonSerializer throws an exception.
Controlling Serialization with Attributes
Attributes let you customize how JsonSerializer handles your types without changing serialization code. You can rename properties, ignore certain fields, control null handling, and set default values. These attributes live on your model classes.
JsonPropertyName changes the JSON property name without affecting your C# property name. JsonIgnore excludes properties entirely. JsonInclude forces serialization of non-public properties. These attributes give you fine-grained control over JSON output.
Here's how to use serialization attributes effectively:
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
var user = new User
{
UserId = 123,
Username = "johndoe",
PasswordHash = "secret_hash",
EmailAddress = "john@example.com",
CreatedDate = DateTime.Now,
LastLoginDate = DateTime.Now.AddHours(-2),
Status = UserStatus.Active
};
var options = new JsonSerializerOptions
{
WriteIndented = true,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull
};
string json = JsonSerializer.Serialize(user, options);
Console.WriteLine(json);
class User
{
// Rename in JSON output
[JsonPropertyName("id")]
public int UserId { get; set; }
[JsonPropertyName("username")]
public string Username { get; set; }
// Exclude from serialization
[JsonIgnore]
public string PasswordHash { get; set; }
// Custom name with camelCase
[JsonPropertyName("email")]
public string EmailAddress { get; set; }
// Include when writing, ignore when reading
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingNull)]
public DateTime? LastLoginDate { get; set; }
public DateTime CreatedDate { get; set; }
// Enum serialization as string
[JsonConverter(typeof(JsonStringEnumConverter))]
public UserStatus Status { get; set; }
// Property order in JSON
[JsonPropertyOrder(1)]
public string Username { get; set; }
[JsonPropertyOrder(2)]
public string EmailAddress { get; set; }
}
enum UserStatus
{
Inactive,
Active,
Suspended,
Deleted
}
JsonStringEnumConverter serializes enums as strings instead of numbers, making JSON more readable and stable across enum changes. JsonIgnoreCondition.WhenWritingNull reduces JSON size by excluding null values. Property order attributes let you control field sequence in the output.
Configuring JsonSerializerOptions
JsonSerializerOptions provides global settings that affect all serialization operations. You configure naming policies, number handling, null behavior, and more. Creating reusable options objects ensures consistency across your application.
For web APIs, you typically configure options once during startup and reuse them everywhere. This keeps serialization behavior consistent and lets you change settings in one place. Common configurations include camelCase naming, case-insensitive property matching, and custom converters.
Here are essential JsonSerializerOptions configurations:
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
var product = new Product
{
ProductId = 1,
ProductName = "Gaming Laptop",
UnitPrice = 1299.99m,
StockQuantity = 15
};
// Standard options for web APIs
var webApiOptions = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
WriteIndented = true,
PropertyNameCaseInsensitive = true,
NumberHandling = JsonNumberHandling.AllowReadingFromString,
Converters = { new JsonStringEnumConverter() }
};
string json = JsonSerializer.Serialize(product, webApiOptions);
Console.WriteLine("Web API Format:");
Console.WriteLine(json);
// Deserialize with case-insensitive matching
string jsonInput = @"{
""productid"": 2,
""productname"": ""Wireless Mouse"",
""unitprice"": ""29.99"",
""stockquantity"": 100
}";
var deserializedProduct = JsonSerializer.Deserialize<Product>(jsonInput, webApiOptions);
Console.WriteLine($"\nDeserialized: {deserializedProduct.ProductName} - ${deserializedProduct.UnitPrice}");
// Options for file storage (compact)
var storageOptions = new JsonSerializerOptions
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingDefault,
WriteIndented = false
};
string compactJson = JsonSerializer.Serialize(product, storageOptions);
Console.WriteLine($"\nCompact for storage: {compactJson}");
// Options for logging (include all data)
var loggingOptions = new JsonSerializerOptions
{
WriteIndented = true,
IncludeFields = true,
MaxDepth = 32
};
class Product
{
public int ProductId { get; set; }
public string ProductName { get; set; }
public decimal UnitPrice { get; set; }
public int StockQuantity { get; set; }
}
PropertyNameCaseInsensitive helps when deserializing JSON from external sources with inconsistent casing. NumberHandling.AllowReadingFromString lets you accept numbers as strings, which some APIs produce. MaxDepth prevents stack overflow from deeply nested or circular objects.
Creating Custom JsonConverters
Custom converters handle types that don't serialize well by default. You might need special date formats, custom object representations, or domain-specific serialization logic. Converters give you complete control over reading and writing JSON.
You create a converter by inheriting from JsonConverter<T> and implementing Read and Write methods. Read parses JSON into your type, while Write converts your type to JSON. Register converters globally in options or apply them to specific properties with attributes.
Here's how to build custom converters for common scenarios:
using System;
using System.Text.Json;
using System.Text.Json.Serialization;
// Custom date format converter
public class CustomDateTimeConverter : JsonConverter<DateTime>
{
private const string Format = "yyyy-MM-dd";
public override DateTime Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
string dateString = reader.GetString();
return DateTime.ParseExact(dateString, Format, null);
}
public override void Write(Utf8JsonWriter writer, DateTime value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString(Format));
}
}
// Version converter for semantic versioning
public class VersionConverter : JsonConverter<Version>
{
public override Version Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
{
string versionString = reader.GetString();
return Version.Parse(versionString);
}
public override void Write(Utf8JsonWriter writer, Version value, JsonSerializerOptions options)
{
writer.WriteStringValue(value.ToString());
}
}
// Usage example
var release = new Release
{
ProductName = "My App",
ReleaseDate = new DateTime(2025, 11, 4),
Version = new Version(2, 1, 0)
};
var options = new JsonSerializerOptions
{
WriteIndented = true,
Converters =
{
new CustomDateTimeConverter(),
new VersionConverter()
}
};
string json = JsonSerializer.Serialize(release, options);
Console.WriteLine(json);
var restored = JsonSerializer.Deserialize<Release>(json, options);
Console.WriteLine($"\nRestored: {restored.ProductName} v{restored.Version} on {restored.ReleaseDate:yyyy-MM-dd}");
class Release
{
public string ProductName { get; set; }
public DateTime ReleaseDate { get; set; }
public Version Version { get; set; }
}
Custom converters can handle validation, transformation, and complex mapping logic. For performance-critical code, converters work with Utf8JsonReader and Utf8JsonWriter directly, avoiding string allocations. You can also create converters that delegate to other converters for composition.
Handling Polymorphic Types
Polymorphic serialization happens when you serialize a base type but the actual instance is a derived type. System.Text.Json needs type discriminators to deserialize back to the correct derived type. .NET 7+ provides built-in support through JsonDerivedType attributes.
Type discriminators are special properties in the JSON that indicate the actual type. During deserialization, JsonSerializer reads this discriminator and creates the appropriate derived type. This enables object-oriented designs with serialization.
Here's how polymorphic serialization works:
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
[JsonPolymorphic(TypeDiscriminatorPropertyName = "$type")]
[JsonDerivedType(typeof(CreditCardPayment), "credit_card")]
[JsonDerivedType(typeof(PayPalPayment), "paypal")]
[JsonDerivedType(typeof(BankTransferPayment), "bank_transfer")]
public abstract class Payment
{
public string PaymentId { get; set; }
public decimal Amount { get; set; }
public DateTime ProcessedDate { get; set; }
}
public class CreditCardPayment : Payment
{
public string CardNumber { get; set; }
public string CardholderName { get; set; }
public string ExpiryDate { get; set; }
}
public class PayPalPayment : Payment
{
public string PayPalEmail { get; set; }
public string TransactionId { get; set; }
}
public class BankTransferPayment : Payment
{
public string AccountNumber { get; set; }
public string BankName { get; set; }
public string RoutingNumber { get; set; }
}
// Usage
var payments = new List<Payment>
{
new CreditCardPayment
{
PaymentId = "PAY001",
Amount = 99.99m,
ProcessedDate = DateTime.Now,
CardNumber = "**** **** **** 1234",
CardholderName = "John Doe",
ExpiryDate = "12/25"
},
new PayPalPayment
{
PaymentId = "PAY002",
Amount = 49.99m,
ProcessedDate = DateTime.Now,
PayPalEmail = "john@example.com",
TransactionId = "TXN123456"
}
};
var options = new JsonSerializerOptions { WriteIndented = true };
string json = JsonSerializer.Serialize(payments, options);
Console.WriteLine("Serialized payments:");
Console.WriteLine(json);
var restoredPayments = JsonSerializer.Deserialize<List<Payment>>(json, options);
foreach (var payment in restoredPayments)
{
Console.WriteLine($"\nType: {payment.GetType().Name}");
Console.WriteLine($"Amount: ${payment.Amount}");
if (payment is CreditCardPayment cc)
Console.WriteLine($"Card: {cc.CardNumber}");
else if (payment is PayPalPayment pp)
Console.WriteLine($"PayPal: {pp.PayPalEmail}");
}
The JsonPolymorphic attribute defines the discriminator property name, while JsonDerivedType maps discriminator values to types. During serialization, System.Text.Json automatically adds the discriminator. During deserialization, it reads the discriminator and creates the correct type. This works seamlessly with collections and nested objects.
Try It Yourself
This complete example demonstrates serialization with custom converters, attributes, and different scenarios. You'll see how to handle real-world data structures.
using System;
using System.Collections.Generic;
using System.Text.Json;
using System.Text.Json.Serialization;
var order = new Order
{
OrderId = 1001,
OrderDate = DateTime.Now,
Customer = new Customer
{
CustomerId = 500,
Name = "Jane Smith",
Email = "jane@example.com"
},
Items = new List<OrderItem>
{
new OrderItem { ProductId = 1, ProductName = "Laptop", Quantity = 1, UnitPrice = 999.99m },
new OrderItem { ProductId = 2, ProductName = "Mouse", Quantity = 2, UnitPrice = 29.99m }
},
Status = OrderStatus.Processing
};
var options = new JsonSerializerOptions
{
WriteIndented = true,
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
Converters = { new JsonStringEnumConverter() }
};
string json = JsonSerializer.Serialize(order, options);
Console.WriteLine("=== Order JSON ===");
Console.WriteLine(json);
var restored = JsonSerializer.Deserialize<Order>(json, options);
Console.WriteLine("\n=== Deserialized Order ===");
Console.WriteLine($"Order #{restored.OrderId}");
Console.WriteLine($"Customer: {restored.Customer.Name}");
Console.WriteLine($"Items: {restored.Items.Count}");
Console.WriteLine($"Total: ${restored.CalculateTotal():F2}");
Console.WriteLine($"Status: {restored.Status}");
class Order
{
public int OrderId { get; set; }
public DateTime OrderDate { get; set; }
public Customer Customer { get; set; }
public List<OrderItem> Items { get; set; }
public OrderStatus Status { get; set; }
[JsonIgnore]
public decimal CalculateTotal()
{
decimal total = 0;
foreach (var item in Items)
total += item.Quantity * item.UnitPrice;
return total;
}
}
class Customer
{
public int CustomerId { get; set; }
public string Name { get; set; }
public string Email { get; set; }
}
class OrderItem
{
public int ProductId { get; set; }
public string ProductName { get; set; }
public int Quantity { get; set; }
public decimal UnitPrice { get; set; }
}
enum OrderStatus
{
Pending,
Processing,
Shipped,
Delivered,
Cancelled
}
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
<Nullable>enable</Nullable>
</PropertyGroup>
</Project>
Running the example:
- Create a new folder and save the files
- Run
dotnet run to execute
- Examine the JSON output structure
- Try adding more items or changing properties
- Experiment with different JsonSerializerOptions
Common Pitfalls & How to Avoid Them
Forgetting to make properties public causes silent serialization failures. System.Text.Json only serializes public properties by default. If your JSON is missing fields, check property accessibility first. Use JsonInclude attribute for non-public properties you need serialized.
Case sensitivity trips up developers moving from Newtonsoft.Json. System.Text.Json is case-sensitive by default during deserialization. Set PropertyNameCaseInsensitive = true in options to match properties regardless of casing. This helps when consuming external APIs with inconsistent naming.
Missing parameterless constructors prevent deserialization. JsonSerializer needs a way to create instances before setting properties. Add a parameterless constructor, even if private, to enable deserialization. For immutable types, use constructor parameters that match property names.
Circular references cause serialization to throw exceptions. If your object graph has cycles, System.Text.Json fails loudly. Set ReferenceHandler.Preserve to handle references with metadata, or restructure your objects to eliminate cycles. Most serialization scenarios work better with tree structures.
Performance suffers when you create JsonSerializerOptions repeatedly. Options objects are expensive to create due to reflection and caching. Create one instance at startup and reuse it everywhere. This improves both performance and consistency across your application.
Date and time zones cause confusion across systems. DateTime serializes without explicit time zone information. Use DateTimeOffset for times that need time zone context. For UTC-only scenarios, convert to UTC before serialization and document this behavior clearly.