Introduction
System.Text.Json limits serialization depth to 64 levels by default to prevent stack overflow from deeply nested or circular object graphs. When serializing Entity Framework entities with navigation properties, the serializer follows relationships infinitely until it hits the depth limit and throws a JsonException. This commonly occurs when returning EF entities directly from API endpoints.
Symptoms
System.Text.Json.JsonException: The maximum read depth (64) has been exceededThe object cycle was detectedin the exception message- API returns 500 Internal Server Error for endpoints returning entities
- Works for simple objects but fails for objects with navigation properties
- Error after adding a new bidirectional relationship between entities
Example error:
``
System.Text.Json.JsonException: A possible object cycle was detected.
This can either be due to a cycle or if the object depth is larger
than the maximum allowed depth of 64.
at System.Text.Json.ThrowHelper.ThrowInvalidOperationException_JsonSerializerMaxDepthExceeded()
at System.Text.Json.Serialization.Converters.ObjectDefaultConverter1.OnTryWrite(...)
```
Common Causes
- Returning EF Core entities with navigation properties directly from controllers
- Bidirectional relationships (Order references OrderItem, OrderItem references Order)
- Parent-child hierarchies (Category has Children, Children reference Parent)
- Self-referencing entities (Employee has Manager who is also an Employee)
- Entity Framework proxy objects with navigation property lazy loading
Step-by-Step Fix
- 1.Use DTOs instead of returning entities:
- 2.```csharp
- 3.// Entity with circular reference
- 4.public class Order
- 5.{
- 6.public int Id { get; set; }
- 7.public string CustomerName { get; set; } = null!;
- 8.public List<OrderItem> Items { get; set; } = new();
- 9.}
public class OrderItem { public int Id { get; set; } public string ProductName { get; set; } = null!; public Order Order { get; set; } = null!; // Back-reference }
// DTO without circular reference public record OrderDto(int Id, string CustomerName, List<OrderItemDto> Items); public record OrderItemDto(int Id, string ProductName);
// Map in controller [HttpGet("orders/{id}")] public async Task<ActionResult<OrderDto>> GetOrder(int id) { var order = await _context.Orders .Include(o => o.Items) .FirstOrDefaultAsync(o => o.Id == id);
if (order == null) return NotFound();
var dto = new OrderDto( order.Id, order.CustomerName, order.Items.Select(i => new OrderItemDto(i.Id, i.ProductName)).ToList() );
return Ok(dto); } ```
- 1.Enable reference handling for cycles (.NET 8+):
- 2.```csharp
- 3.builder.Services.AddControllers()
- 4..AddJsonOptions(options =>
- 5.{
- 6.options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.Preserve;
- 7.options.JsonSerializerOptions.MaxDepth = 64;
- 8.});
// JSON output includes $id and $ref: // {"$id":"1","Id":1,"Items":{"$id":"2","$values":[{"$ref":"1"}]}} ```
- 1.Use [JsonIgnore] to break cycles:
- 2.```csharp
- 3.public class OrderItem
- 4.{
- 5.public int Id { get; set; }
- 6.public string ProductName { get; set; } = null!;
[JsonIgnore] // Do not serialize the back-reference public Order Order { get; set; } = null!; } ```
- 1.Configure ASP.NET Core to ignore cycles (not recommended for production):
- 2.```csharp
- 3.builder.Services.AddControllers()
- 4..AddJsonOptions(options =>
- 5.{
- 6.options.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles;
- 7.});
// Null is written for the back-reference // {"Id":1,"Items":[{"Id":1,"ProductName":"Widget","Order":null}]} ```
Prevention
- Never return EF entities directly from API endpoints; always use DTOs or records
- Use tools like AutoMapper or Mapster for entity-to-DTO mapping
- Enable
ReferenceHandler.IgnoreCyclesas a safety net - Use
[JsonIgnore]on back-reference navigation properties - Add API response validation tests that verify JSON serialization
- Consider using GraphQL to let clients control the depth of included relationships