Fixed: "A possible object cycle was detected" When Returning EF Core Entities from a Web API
An endpoint that returned EF Core entities answered 500, with JsonException: A possible object cycle was detected. The path in the message gave it away: $.Products.Category.Products.Category.Products…. A category has products, and each product points back to its category, so the JSON would never end. The fix isn't a JSON setting: it's not returning entities in the first place.
What I Was Doing
In a .NET 10 Web API with EF Core 10, I returned categories with their products from an endpoint, loading the products with Include:
app.MapGet("/demo/entity-cycle", async (InventoryDbContext db) =>
{
var categories = await db.Categories.Include(c => c.Products).ToListAsync();
return Results.Ok(categories);
});
The two entities point at each other, as navigations normally do:
// Category
public ICollection<Product> Products { get; } = [];
// Product
public Category Category { get; set; } = null!;
What I Expected
200 OK, with each category and a list of its products.
What I Got
HTTP/1.1 500 Internal Server Error
Content-Type: application/problem+json
{
"type": "https://tools.ietf.org/html/rfc9110#section-15.6.1",
"title": "An error occurred while processing your request.",
"status": 500,
"traceId": "00-01fed7d34d160ef7fe429033a93d1f38-1b77050af5b1a51f-00"
}
fail: Microsoft.AspNetCore.Diagnostics.ExceptionHandlerMiddleware[1]
An unhandled exception has occurred while executing the request.
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. Consider using ReferenceHandler.Preserve on JsonSerializerOptions to support cycles. Path: $.Products.Category.Products.Category.Products.Category.Products.Category…

What I Checked
Whether the query worked. It did. The log shows the LEFT JOIN ran before the failure, so the data was fine, and this is about writing the JSON:
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT `c`.`Id`, `c`.`CreatedAtUtc`, `c`.`Name`, `p`.`Id`, `p`.`ArchivedAtUtc`, `p`.`CategoryId`, `p`.`CreatedAtUtc`, `p`.`Description`, `p`.`IsArchived`, `p`.`Name`, `p`.`QuantityOnHand`, `p`.`ReorderLevel`, `p`.`Sku`, `p`.`UnitPrice`, `p`.`UpdatedAtUtc`, `p`.`Version`
FROM `categories` AS `c`
LEFT JOIN `products` AS `p` ON `c`.`Id` = `p`.`CategoryId`
ORDER BY `c`.`Id`
Why the products point back. I only asked for c.Products, but EF Core also fills in Product.Category by itself. EF Core's eager loading page has a tip on this: "Entity Framework Core will automatically fix-up navigation properties to any other entities that were previously loaded into the context instance." So each product's Category was set, and that category's Products contains the product.
Which option the message suggests. ReferenceHandler.Preserve adds $id and $ref properties to the JSON, which most clients don't expect. System.Text.Json's page also describes IgnoreCycles: "The serializer sets circular reference properties to null". I tried it, to see what the client would get:
builder.Services.ConfigureHttpJsonOptions(options =>
options.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles);
[
{
"id": 1,
"name": "Cables",
"createdAtUtc": "2026-11-04T11:42:43.821656Z",
"products": [
{
"id": 1,
"sku": "CBL-USBC-2M",
"name": "USB-C cable, 2 m",
"description": "Braided, 100 W",
"categoryId": 1,
"category": null,
It answers 200. But every column of every product goes out, including internal ones, and "category": null is misleading: the product does have a category.
Why It Happens
System.Text.Json writes an object by writing all its public properties, and follows them as deep as they go. Category → Products → Category → Products never ends, so it stops at its limit of 64 levels and reports a cycle. The entities are designed for EF Core, and those navigations are useful there. They just aren't a description of what your API should send.
The Fix
Return a record that describes the response, and let Select build it. Here, each category with the number of products it has:
var categories = await db.Categories
.OrderBy(c => c.Name)
.Select(c => new { c.Name, ProductCount = c.Products.Count })
.ToListAsync();
return Results.Ok(categories);
There's no Include, because Select tells EF Core what's needed. In a real API, use a named record instead of an anonymous type, as in How to Select Only the Columns You Need.
After the Fix
HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
[
{
"name": "Adapters",
"productCount": 2
},
{
"name": "Cables",
"productCount": 4
},
{
"name": "Chargers",
"productCount": 2
}
]
info: Microsoft.EntityFrameworkCore.Database.Command[20101]
Executed DbCommand (1ms) [Parameters=[], CommandType='Text', CommandTimeout='30']
SELECT `c`.`Name`, (
SELECT COUNT(*)
FROM `products` AS `p`
WHERE `c`.`Id` = `p`.`CategoryId`) AS `ProductCount`
FROM `categories` AS `c`
ORDER BY `c`.`Name`

Smaller JSON, a cheaper query, and no way for a new navigation to change your API by accident.
Common Mistakes
| Mistake | What happens | Do this instead |
|---|---|---|
Using IgnoreCycles and carrying on | Entities still leak every column, and some navigations come out null | Return records |
Using [JsonIgnore] on navigations | It works, until an endpoint needs that navigation | Keep JSON concerns out of entities |
Using Preserve | Clients have to understand $id and $ref | Return records |
| Removing the navigation from the entity | Queries and configuration lose it | Keep it; don't serialize the entity |
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
A possible object cycle was detected with a repeating path | Entities with two-way navigations in the response | Return records |
| The same message with a long path that doesn't repeat | Real data nested deeper than 64 levels | Raise MaxDepth, or flatten the response |
No error, but "category": null on products | The navigation wasn't loaded, or IgnoreCycles cut it | Project the value you need |
| The error appears only on some rows | Only rows with related data loaded have a cycle | The same fix |
FAQ
Why did it work without Include?
Without Include, Products stays empty, and there's no cycle to follow. The error appears as soon as both ends are loaded.
Should a record contain another record?
Yes, if the client needs it, such as a category with a list of product summaries. Records only contain what you put in them, so there's no way back up.
Conclusion
"A possible object cycle was detected" means the JSON writer followed your entities' navigations in a circle, usually after Include. JSON options can hide it, but they keep sending every column. Use Select with a response record, and the cycle can't exist.
Additional Resources
- How to preserve references in System.Text.Json —
PreserveandIgnoreCycles. - Eager Loading of Related Data —
Includeand fix-up.
