A Possible Object Cycle Was Detected in ASP.NET Core: Causes and Fixes

You called an endpoint that returns an Entity Framework Core entity, and instead of clean JSON you got a wall of red: System.Text.Json.JsonException: A possible object cycle was detected. If you are seeing that error, it almost always means your response object references itself through EF Core navigation properties, and the serializer gave up trying to walk the loop. It is one of the most common serialization failures in ASP.NET Core, and the fix is usually a one-line configuration change - or a slightly bigger architectural decision that saves you from a whole class of bugs.
I have hit this on more production APIs than I can count, usually right after someone wires up a new Order and Customer relationship and returns the entity straight from the controller. The quick fixes work, but which one you pick has real consequences for your JSON payload shape and your clients. If you want the full pattern - response DTOs, projections, and the serializer settings wired into a complete production API - the annotated source code on Patreon walks through it end to end, including the edge cases that only show up under load.
The reason this error is so easy to trip over is that the real fix is not "configure the serializer" - it is "stop serializing your database entities directly." Getting that right means shaping response DTOs and EF Core read queries together, which is exactly what Chapter 2 and Chapter 3 of the Zero to Production course cover inside one running ASP.NET Core API, so the context is always clear.
What Does "A Possible Object Cycle Was Detected" Mean?
The full error text from System.Text.Json reads like this:
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.
There are two distinct triggers hiding in that one message:
A reference cycle - object A points to object B, and object B points back to object A. The serializer would loop forever, so it throws instead.
Depth overflow - the object graph is legitimately deeper than the configured
MaxDepth(64 by default). This is rarer, but a badly shaped graph or an accidental include chain can hit it.
The cycle case is by far the most common in ASP.NET Core, and EF Core is usually the source. When EF Core loads related data, it performs navigation fix-up: if you load a Customer with its Orders, each Order gets a populated Customer reference pointing right back. That is a cycle, and Microsoft's own EF Core serialization guidance documents exactly this behavior.
Why EF Core Navigation Properties Cause the Cycle
Here is the shape that produces the error nearly every time - two entities with bidirectional navigation properties:
public class Order
{
public int Id { get; set; }
public Customer Customer { get; set; } = null!; // Order -> Customer
}
public class Customer
{
public int Id { get; set; }
public List<Order> Orders { get; set; } = new(); // Customer -> Orders (back-reference)
}
Serialize an Order and System.Text.Json walks Order to Customer to Orders back to the same Order, and around it goes. Nothing is wrong with your model - bidirectional navigation properties are normal in EF Core. The mistake is returning the raw entity from the API surface, where a serializer has to traverse the entire graph.
The trap is that it can work in development and then fail in production. If your dev query never loaded the back-reference (lazy loading off, no Include), the Orders collection stays empty and there is no cycle. Add an Include later, or turn on lazy loading, and the same endpoint suddenly throws. That intermittent behavior is what bites teams the most.
How Do You Fix the Object Cycle Error in ASP.NET Core?
There are three practical fixes and one you should reach for last. Here they are in the order I actually recommend them.
Fix 1: Configure ReferenceHandler.IgnoreCycles (Quickest)
Available since .NET 6, ReferenceHandler.IgnoreCycles tells the serializer to write null when it detects a reference it has already visited, instead of throwing. For a controller-based API:
using System.Text.Json.Serialization;
builder.Services.AddControllers()
.AddJsonOptions(o =>
o.JsonSerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles);
For a minimal API, the entry point is different - configure the HTTP JSON options instead:
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles);
This is the fastest way to stop the exception. The trade-off: the looping property serializes as null, so order.customer.orders[0].customer comes back null. That is usually fine, but it does mean your JSON quietly loses data at the cycle point.
Fix 2: Ignore the Back-Reference with [JsonIgnore]
If only one direction of the relationship matters in your API responses, mark the property that closes the loop so the serializer never traverses it:
public class Customer
{
public int Id { get; set; }
[JsonIgnore] // System.Text.Json.Serialization
public List<Order> Orders { get; set; } = new();
}
This is precise and predictable - you decide exactly which navigation property disappears from the payload. The downside is that you are annotating your domain entity with serialization concerns, which couples your data model to your API shape. That coupling is the very thing the next fix removes.
Fix 3: Return DTOs or Projections (The Real Fix)
The cleanest solution is to never hand an EF Core entity to the serializer at all. Project into a purpose-built response type with a Select, and the cycle simply cannot exist because you only pull the fields you want:
var orders = await db.Orders
.AsNoTracking()
.Select(o => new OrderDto(o.Id, o.Customer.Name, o.Total))
.ToListAsync();
There is no back-reference in OrderDto, so there is nothing to loop through. As a bonus, AsNoTracking() skips change-tracking overhead on a read-only query, and the projection means EF Core generates SQL that fetches only those columns rather than the whole row graph. This is the pattern I ship in production, and it makes the serializer settings irrelevant. If you have already fought other EF Core footguns, our writeup on common EF Core mistakes in ASP.NET Core covers why returning entities directly ranks near the top of that list.
The Fix to Avoid: ReferenceHandler.Preserve
The error message itself suggests ReferenceHandler.Preserve, and it does stop the exception - but read what it does before you use it. Preserve emits reference-tracking metadata into your JSON: $id, $ref, and $values properties that let a compatible deserializer rebuild the original object graph.
{ "$id": "1", "id": 10, "customer": { "$id": "2", "orders": { "$values": [ { "$ref": "1" } ] } } }
That shape is great for round-tripping a C# object graph back into C#. It is a poor fit for a public API, because most JavaScript clients, mobile apps, and third-party consumers have no idea what $ref means and will choke on it. Reach for Preserve only when both ends are .NET and you genuinely need to reconstruct shared references. Microsoft's guide on preserving references in System.Text.Json spells out the metadata format if you want the details.
What If the Error Is About Depth, Not a Cycle?
Occasionally the graph has no true cycle but is still deeper than MaxDepth (64 by default). Raising the limit looks tempting:
o.JsonSerializerOptions.MaxDepth = 128; // treats the symptom, not the cause
Resist it. A response that is 64 levels deep is a design smell, not a configuration gap. It usually means an over-eager chain of Include calls or an entity graph that was never meant to cross the wire. Flatten it into a DTO instead - the same projection fix from above solves the depth error and the cycle error at once.
How to Avoid the Error Entirely
The durable answer is a boundary rule: entities stay inside your data layer, DTOs cross the API boundary. A few habits make that automatic:
Never return
DbSetentities from controllers or minimal API handlers. Map to a response record first.Project in the query with
Select, not after materializing. You avoid the cycle and cut the SQL down to the columns you need.Keep navigation properties for querying, not for serializing. They exist so EF Core can join; they are not your wire format.
Set a single global JSON policy (such as
IgnoreCycles) as a safety net, but treat it as belt-and-suspenders behind DTOs, not as the primary fix.
Whether you standardize on System.Text.Json defaults or need Newtonsoft for a specific feature is a separate decision - if you are weighing that, our comparison of System.Text.Json vs Newtonsoft.Json breaks down where each still wins in 2026.
Frequently Asked Questions
What causes "a possible object cycle was detected" in System.Text.Json?
It is caused by a reference loop in the object you are serializing, most often EF Core bidirectional navigation properties (a Customer with Orders, where each Order references its Customer). System.Text.Json refuses to follow the loop and throws. Less commonly, it fires when the object graph is deeper than the default MaxDepth of 64.
Does ReferenceHandler.IgnoreCycles change my JSON output?
Yes. When the serializer reaches a reference it has already written, it emits null instead of that object. So a nested back-reference like order.customer.orders[0].customer comes back as null. The payload stays valid JSON and standard clients parse it fine, but you lose data at the exact point where the cycle would have been.
What is the difference between ReferenceHandler.IgnoreCycles and ReferenceHandler.Preserve?
IgnoreCycles replaces repeated references with null and produces plain, client-friendly JSON. Preserve keeps the references by writing $id and $ref metadata so a .NET deserializer can rebuild the shared object graph. Use IgnoreCycles (or DTOs) for public APIs; use Preserve only when both ends are .NET and need the original references restored.
How do I fix the object cycle error in a minimal API?
Minimal APIs do not use AddControllers().AddJsonOptions(...). Configure builder.Services.ConfigureHttpJsonOptions(o => o.SerializerOptions.ReferenceHandler = ReferenceHandler.IgnoreCycles) instead. Better still, return a DTO projection from your handler so there is no cycle to configure around in the first place.
Is returning EF Core entities directly from a controller a bad practice?
Generally, yes. Beyond the cycle error, it leaks your database schema into your API contract, over-fetches columns, and couples clients to your data model. Returning DTOs or projecting with Select avoids all of that and makes the serializer configuration a non-issue. It is the fix that prevents the error rather than suppressing it.
About the Author
I'm Celin Daniel, Co-founder of Coding Droplets. I've been building .NET and ASP.NET Core systems in production for 13+ years - APIs, distributed backends, enterprise platforms. Everything I write here comes from real shipping experience: patterns that held up, trade-offs that bit us, and lessons learned the hard way.
GitHub: codingdroplets
YouTube: Coding Droplets
Website: codingdroplets.com






