JSON is the universal data exchange format for web APIs. ASP.NET Core uses System.Text.Json (the built-in JSON library) for all request/response serialisation by default. Understanding how to configure it, how to control serialisation behaviour with attributes, and how to efficiently serialise to/from streams (rather than strings) is essential for building correct, performant ASP.NET Core APIs. System.Text.Json is significantly faster than the older Newtonsoft.Json library and allocates far less memory for typical API payloads.
Basic Serialisation
using System.Text.Json;
using System.Text.Json.Serialization;
public record Post(int Id, string Title, string Body, bool IsPublished, DateTime CreatedAt);
// ── Serialise to JSON string ───────────────────────────────────────────────
var post = new Post(1, "Hello World", "First post body.", true, DateTime.UtcNow);
string json = JsonSerializer.Serialize(post);
// {"id":1,"title":"Hello World","body":"First post body.","isPublished":true,"createdAt":"..."}
// ── Deserialise from JSON string ──────────────────────────────────────────
Post? restored = JsonSerializer.Deserialize<Post>(json);
// ── Configuring options ────────────────────────────────────────────────────
var options = new JsonSerializerOptions
{
PropertyNamingPolicy = JsonNamingPolicy.CamelCase, // camelCase keys
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, // omit nulls
WriteIndented = true, // pretty-print
PropertyNameCaseInsensitive = true, // case-insensitive deserialization
Converters = { new JsonStringEnumConverter() } // enum as string, not number
};
string prettyJson = JsonSerializer.Serialize(post, options);
// ── Register globally in ASP.NET Core ────────────────────────────────────
// Program.cs
builder.Services
.AddControllers()
.AddJsonOptions(opts =>
{
opts.JsonSerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.CamelCase;
opts.JsonSerializerOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
opts.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter());
});
Note: By default,
System.Text.Json uses case-sensitive property matching during deserialisation. If your JSON payload uses "userId": 42 but your C# property is named UserId, deserialisation will fail to bind the value unless you set PropertyNameCaseInsensitive = true. ASP.NET Core’s default JSON configuration sets this to true for model binding, but if you are manually deserialising JSON strings (config files, webhook payloads), you may need to set it explicitly.Tip: Create a static readonly
JsonSerializerOptions instance and reuse it. Creating a new JsonSerializerOptions on every serialisation call is expensive — the options object builds internal caches (type metadata, converter lookup tables) that take CPU time to construct. A static readonly instance builds these caches once and reuses them across all serialisation calls. This is one of the most impactful easy performance wins in high-throughput ASP.NET Core APIs.Warning: Circular reference handling is not enabled by default in
System.Text.Json. If you serialise an EF Core entity with navigation properties that reference each other (Post → Author → Posts), you get a JsonException: A possible object cycle was detected. Fix by using projection to DTOs (removing circular references at the data layer) rather than enabling ReferenceHandler.Preserve (which produces non-standard JSON with $id and $ref annotations that Angular clients do not expect).JSON Attributes
public class UserDto
{
[JsonPropertyName("user_id")] // use snake_case in JSON
public int Id { get; set; }
public string Name { get; set; } = string.Empty;
[JsonIgnore] // never serialise this property
public string PasswordHash { get; set; } = string.Empty;
[JsonPropertyOrder(1)] // control output order
public string Email { get; set; } = string.Empty;
[JsonConverter(typeof(JsonStringEnumConverter))]
public UserRole Role { get; set; } // serialize as "Admin" not 0
[JsonIgnore(Condition = JsonIgnoreCondition.WhenWritingDefault)]
public int? PhoneExtension { get; set; } // omit if null or 0
}
// Serialise to/from Stream (more efficient than string — avoids intermediate allocation)
using var outputStream = File.Create("users.json");
await JsonSerializer.SerializeAsync(outputStream, users, options);
using var inputStream = File.OpenRead("users.json");
var loaded = await JsonSerializer.DeserializeAsync<List<UserDto>>(inputStream, options);
Common Mistakes
Mistake 1 — Circular references when serialising EF Core entities directly
❌ Wrong — throws JsonException:
return Ok(post); // Post.Author.Posts → Post.Author ... circular!
✅ Correct — project to a DTO that has no circular references:
return Ok(new PostDto { Id = post.Id, Title = post.Title, AuthorName = post.Author.Name });
Mistake 2 — Creating JsonSerializerOptions on every call (performance hit)
❌ Wrong — rebuilds internal caches on every request:
string json = JsonSerializer.Serialize(obj, new JsonSerializerOptions { WriteIndented = true });
✅ Correct — use a shared static instance:
private static readonly JsonSerializerOptions _opts = new() { WriteIndented = true };
string json = JsonSerializer.Serialize(obj, _opts);