IsConstrained(false) relationship causes false circular dependency during SaveChanges
- Dominant language
- C#
- Stars
- 14.8k
- Forks
- 3.4k
- PR merge metrics
- PR metrics pending
Description
### Bug description
## Summary
In EF Core 11, a relationship configured with `IsConstrained(false)` still participates as a non-breakable dependency in the `SaveChanges` modification-command graph.
This can cause `SaveChanges()` to throw a circular dependency exception even though there is no corresponding database foreign key constraint and the database can persist the graph safely.
A common example is:
* Entity A has a SQL Server `IDENTITY int` primary key.
* Entity B has a client-generated `Guid` primary key.
* A contains `BId`, which references `B.Id` through an **unconstrained** EF relationship (`IsConstrained(false)`).
* B contains `RootId`, which is a normal **constrained** FK referencing `A.Id`.
The database-valid insert order is:
1. Insert A, including the already-known `BId` GUID. Since this relationship is unconstrained, B does not need to exist yet.
2. Obtain A's generated `IDENTITY` value.
3. Insert B using that value as `RootId`.
This should be possible in a single `SaveChanges()` call. EF may of course use a separate internal batch to retrieve and propagate the store-generated identity value.
Currently EF detects:
```text
A -> B // because A.BId references B.Id, even though IsConstrained(false)
B -> A // real FK B.RootId -> A.Id
```
and reports a circular dependency before executing the valid insert sequence.
## Minimal reproduction
Using:
```text
Microsoft.EntityFrameworkCore.SqlServer 11.0.0-rc.1.26425.128
```
Example:
```csharp
using Microsoft.EntityFrameworkCore;
public sealed class ReproContext : DbContext
{
public DbSet EntitiesA => Set();
public DbSet EntitiesB => Set();
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options
.UseSqlServer(
@"Server=(localdb)\mssqllocaldb;" +
@"Database=EfCoreUnconstrainedCircularDependency;" +
@"Trusted_Connection=True;" +
@"TrustServerCertificate=True")
.EnableSensitiveDataLogging();
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity(builder =>
{
builder.HasKey(x => x.Id);
// SQL Server IDENTITY
builder.Property(x => x.Id)
.ValueGeneratedOnAdd();
// A -> B is intentionally NOT backed by a database FK constraint.
//
// B.Id is client-generated and is therefore already known when A
// is inserted.
builder.HasOne(x => x.EntityB)
.WithMany()
.HasForeignKey(x => x.EntityBId)
.IsConstrained(false);
});
modelBuilder.Entity(builder =>
{
builder.HasKey(x => x.Id);
// Client-generated Guid.
builder.Property(x => x.Id)
.ValueGeneratedNever();
// This IS a real database FK.
// A must therefore be inserted first so that its IDENTITY value
// can be propagated into EntityB.RootId.
builder.HasOne(x => x.Root)
.WithMany()
.HasForeignKey(x => x.RootId)
.OnDelete(DeleteBehavior.NoAction);
});
}
}
public sealed class EntityA
{
public int Id { get; set; }
public Guid EntityBId { get; set; }
public EntityB EntityB { get; set; } = null!;
}
public sealed class EntityB
{
public Guid Id { get; set; }
public int RootId { get; set; }
public EntityA Root { get; set; } = null!;
}
```
Reproduction:
```csharp
await using var db = new ReproContext();
await db.Database.EnsureDeletedAsync();
await db.Database.EnsureCreatedAsync();
var entityBId = Guid.NewGuid();
var a = new EntityA
{
EntityBId = entityBId
};
var b = new EntityB
{
Id = entityBId,
Root = a
};
a.EntityB = b;
db.AddRange(a, b);
await db.SaveChangesAsync();
```
`SaveChangesAsync()` currently fails because EF detects a circular dependency.
## Expected behavior
`SaveChanges()` should succeed.
The required ordering is unambiguous:
```text
INSERT EntityA
EntityBId =
retrieve EntityA.Id
INSERT EntityB
Id =
RootId = EntityA.Id
```
There is no database constraint requiring EntityB to exist before EntityA is inserted because the `EntityA.EntityBId -> EntityB.Id` relationship is configured with:
```csharp
.IsConstrained(false)
```
The normal constrained relationship:
```text
EntityB.RootId -> EntityA.Id
```
must still participate in command ordering and, because `EntityA.Id` is store-generated, must still create the appropriate batching boundary.
## Actual behavior
The unconstrained relationship participates in the modification-command dependency graph in the same way as a constrained relationship.
This creates:
```text
EntityB -> EntityA
EntityA -> EntityB
```
and `CommandBatchPreparer.TopologicalSort()` reports a circular dependency.
## Why I believe this is specific to `IsConstrained(false)`
`IsConstrained(false)` explicitly means that the EF relationship is not backed by a database foreign key constraint.
The EF Core 11 implementation already takes this into account for:
* relational model generation / migrations;
* query join semantics;
* the assumption that a matching principal exists.
However, command ordering still appears to treat the model-level foreign key as a mandatory store-ordering dependency.
`CommandBatchPreparer` currently handles model foreign keys without a mapped relational constraint in `GetForeignKeyValues()` / `AddForeignKeyEdges()` and eventually adds the edge using:
```csharp
_modificationCommandGraph.AddEdge(
predecessor,
command,
new CommandDependency(foreignKey),
requiresBatchingBoundary);
```
The dependency therefore isn't breakable even when:
```csharp
foreignKey.IsConstrained == false
```
and no store-generated value needs to be propagated across that particular relationship.
## Possible implementation direction
Would it make sense for an unconstrained FK dependency to be breakable when it is not required for store-generated value propagation?
Conceptually something along the lines of:
```csharp
var breakable =
!foreignKey.IsConstrained
&& !requiresBatchingBoundary;
_modificationCommandGraph.AddEdge(
predecessor,
command,
new CommandDependency(
foreignKey,
Breakable: breakable),
requiresBatchingBoundary);
```
This would preserve the current useful ordering preference for unconstrained relationships, but would allow the topological sorter to break that edge when it is the only reason for a cycle.
It also preserves required ordering for cases where the principal key itself is store-generated.
For the reproduction above:
```text
EntityB -> EntityA
```
from the unconstrained `EntityA.EntityBId` relationship could be broken.
The real:
```text
EntityA -> EntityB
```
dependency from `EntityB.RootId` remains non-breakable and retains its batching boundary because `EntityA.Id` is store-generated.
The resulting operation is therefore:
```text
EntityA
↓ retrieve IDENTITY
EntityB
```
which matches the actual database constraints.
[EfCore11UnconstrainedCircularRepro.zip](https://github.com/user-attachments/files/32164230/EfCore11UnconstrainedCircularRepro.zip)
## Related issues
This seems closely related to the EF Core 11 implementation of unconstrained foreign-key relationships:
* #13146 — Support "unconstrained" foreign key relationships
* #38361 — implementation of `IsConstrained`
* #29183 – Circular dependency bei 1:n + 1:1
There are older issues involving circular relationships, such as #29183, but those predate `IsConstrained(false)` and involve relationships where both sides are treated as constrained.
This may look similar to #1699 / #29183, but this case is fundamentally different. Those issues contain a real circular database dependency and require cycle breaking by inserting a nullable FK as null and updating it afterwards.
In this case, one relationship is explicitly configured with IsConstraint(false), so there is no corresponding database FK and therefore no circular database dependency to break. A valid command ordering exists naturally: insert A, retrieve its generated identity, then insert B.
The question is whether a relationship configured with IsConstraint(false) should participate in CommandBatchPreparer dependency ordering at all.
I could not find an existing issue covering the specific case where an `IsConstrained(false)` relationship creates an otherwise artificial `SaveChanges` cycle.
### Your code
```csharp
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
const string defaultConnectionString =
@"Server=(localdb)\mssqllocaldb;Database=EfCore11UnconstrainedCircularRepro;Trusted_Connection=True;TrustServerCertificate=True";
var connectionString = Environment.GetEnvironmentVariable("EF_REPRO_CONNECTION")
?? defaultConnectionString;
Console.WriteLine("EF Core 11 IsConstrained(false) circular SaveChanges repro");
Console.WriteLine("----------------------------------------------------------");
Console.WriteLine($"Connection: {connectionString}");
Console.WriteLine();
Console.WriteLine("WARNING: The repro deletes and recreates the configured database.");
Console.WriteLine();
await using var db = new ReproContext(connectionString);
await db.Database.EnsureDeletedAsync();
await db.Database.EnsureCreatedAsync();
var entityBId = Guid.NewGuid();
var a = new EntityA
{
// A.Id is SQL Server IDENTITY and is therefore unknown until A is inserted.
// B.Id, however, is already known now.
EntityBId = entityBId
};
var b = new EntityB
{
Id = entityBId,
// Real constrained relationship. EF must insert A first, obtain its IDENTITY
// value, propagate it into B.RootId and then insert B.
Root = a
};
// Unconstrained relationship. This is intentionally *not* backed by a database FK.
// Since B.Id is already known, SQL Server can legally insert A before B.
a.EntityB = b;
db.AddRange(a, b);
Console.WriteLine("Tracked graph before SaveChanges:");
Console.WriteLine($" A.Id = {a.Id} (store-generated IDENTITY)");
Console.WriteLine($" A.EntityBId = {a.EntityBId}");
Console.WriteLine($" B.Id = {b.Id} (client-generated Guid)");
Console.WriteLine($" B.RootId = {b.RootId} (must receive A.Id)");
Console.WriteLine();
Console.WriteLine("Expected valid database ordering:");
Console.WriteLine(" 1. INSERT A with the already-known B Guid (no DB FK constraint A -> B)");
Console.WriteLine(" 2. Read generated A.Id");
Console.WriteLine(" 3. INSERT B with RootId = A.Id (real DB FK B -> A)");
Console.WriteLine();
try
{
await db.SaveChangesAsync();
Console.ForegroundColor = ConsoleColor.Green;
Console.WriteLine("SaveChanges SUCCEEDED.");
Console.ResetColor();
Console.WriteLine();
Console.WriteLine("By running an EF Core version containing a fix for the issue, this is expected.");
Console.WriteLine($"Generated A.Id: {a.Id}");
Environment.ExitCode = 0;
}
catch (Exception ex)
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine("SaveChanges FAILED.");
Console.ResetColor();
Console.WriteLine();
Console.WriteLine(ex);
Console.WriteLine();
Console.WriteLine("This reproduces the issue if the exception reports a circular dependency between A and B.");
Environment.ExitCode = 1;
}
public sealed class ReproContext(string connectionString) : DbContext
{
public DbSet EntitiesA => Set();
public DbSet EntitiesB => Set();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
=> optionsBuilder
.UseSqlServer(connectionString)
.EnableSensitiveDataLogging()
.EnableDetailedErrors()
.LogTo(Console.WriteLine, LogLevel.Information);
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
modelBuilder.Entity(builder =>
{
builder.ToTable("EntityA");
builder.HasKey(x => x.Id);
// SQL Server IDENTITY int PK.
builder.Property(x => x.Id)
.UseIdentityColumn();
// B.Id is client-generated and is known before SaveChanges.
// This EF navigation is intentionally NOT backed by a database FK constraint.
builder.HasOne(x => x.EntityB)
.WithMany()
.HasForeignKey(x => x.EntityBId)
.OnDelete(DeleteBehavior.NoAction)
.IsConstrained(false);
});
modelBuilder.Entity(builder =>
{
builder.ToTable("EntityB");
builder.HasKey(x => x.Id);
builder.Property(x => x.Id)
.ValueGeneratedNever();
// This is a real database FK. A must exist before B can be inserted.
builder.HasOne(x => x.Root)
.WithMany()
.HasForeignKey(x => x.RootId)
.OnDelete(DeleteBehavior.NoAction);
});
}
}
public sealed class EntityA
{
public int Id { get; set; }
public Guid EntityBId { get; set; }
public EntityB EntityB { get; set; } = null!;
}
public sealed class EntityB
{
public Guid Id { get; set; }
public int RootId { get; set; } // Referring back to A with real database FK constraint.
public EntityA Root { get; set; } = null!;
}
```
### Stack traces
```text
```
### Verbose output
```text
```
### EF Core version
11.0.0-rc.1.26425.128
### Database provider
Microsoft.EntityFrameworkCore.SqlServer
### Target framework
v11.0.100-rc.1
### Operating system
Windows 11
### IDE
Visual Studio 2026 18.10.0
Contributor guide
Assessment
This issue has not been assessed yet.