dotnet/efcore · error · InvalidOperationException
When using AutoTransactionBehavior.Always with the Cosmos…
Error message
When using AutoTransactionBehavior.Always with the Cosmos DB provider, only 1 entity can be saved at a time when using pre- or post- triggers to ensure atomicity.
What it means
When AutoTransactionBehavior.Always is set, EF Core attempts to save all changes as a single atomic Cosmos transactional batch. If any entity has a database trigger, it must be written individually (single-write) to honor the trigger, and atomicity can then only be guaranteed when there is exactly one document root being saved. With 2+ root entries plus a triggered entry, the provider cannot preserve atomicity and throws.
Solutions
- Split the SaveChanges call so that triggered root entities are saved one at a time (one root per SaveChanges).
- Revert AutoTransactionBehavior to the default (Never) for batches that mix triggered and non-triggered entities.
- Remove the triggers from the affected entity types if atomicity across multiple roots is required.
Example fix
// before
dbContext.Database.AutoTransactionBehavior = AutoTransactionBehavior.Always;
await dbContext.SaveChangesAsync(); // mixes triggered + multiple roots
// after - save each triggered root separately
foreach (var root in rootsToSave)
{
dbContext.ChangeTracker.Entries().ToList().ForEach(e => e.State = EntityState.Detached);
dbContext.Attach(root);
await dbContext.SaveChangesAsync();
} Defensive patterns
Strategy: validation
Validate before calling
// Before SaveChangesAsync, verify the Always + trigger + multi-root condition
if (dbContext.Database.AutoTransactionBehavior == AutoTransactionBehavior.Always)
{
var hasTrigger = dbContext.ChangeTracker.Entries()
.Any(e => e.Metadata.GetTriggers().Any());
var rootCount = dbContext.ChangeTracker.Entries()
.Where(e => e.Metadata.IsDocumentRoot())
.Select(e => e.Metadata)
.Distinct()
.Count();
if (hasTrigger && rootCount >= 2)
throw new InvalidOperationException("Save triggered entities one root at a time under Always.");
} Try / catch
try { await dbContext.SaveChangesAsync(ct); }
catch (InvalidOperationException ex)
when (ex.Message.Contains("AutoTransactionBehavior.Always") && ex.Message.Contains("trigger"))
{
// split the batch: save one triggered root per SaveChanges, then retry.
} Prevention
- Avoid setting AutoTransactionBehavior.Always when entities have triggers.
- Save triggered root entities one at a time.
- Audit trigger usage whenever enabling Always.
When it happens
Trigger: Calling SaveChangesAsync with AutoTransactionBehavior == Always, where at least one changed entity has a trigger (entry.Entry.EntityType.GetTriggers().Any() is true) AND rootEntriesToSave.Count >= 2. See CosmosDatabaseWrapper.cs:284-286.
Common situations: Configuring Database.AutoTransactionBehavior = AutoTransactionBehavior.Always globally, then saving a batch where one entity has a pre/post trigger and others do not. Trigger-using audit/_vector scenarios combined with batch saves.
Related errors
- When using AutoTransactionBehavior.Always with the Cosmos…
- Azure Cosmos DB does not support synchronous I/O. Make sure…
- The entity of type ' ' is mapped as a part of the document…
- The entity of type ' ' is mapped as part of the document…
- Trigger ' ' is defined on entity type ' ' which inherits…
AI-assisted analysis of dotnet/efcore@3a2006ef56 (2026-08-11).
Data as JSON: /api/errors/96b4ae9260d511f5.
Report an issue: GitHub.
Appendix: source
Thrown at src/EFCore.Cosmos/Storage/Internal/CosmosDatabaseWrapper.cs:286
foreach (var entry in cosmosUpdateEntries)
{
if (entry.Entry.EntityType.GetTriggers().Any())
{
singleUpdateEntries.Add(entry);
}
else
{
batchableEntries.Add(entry);
}
}
if (_currentDbContext.Context.Database.AutoTransactionBehavior == AutoTransactionBehavior.Always)
{
if (singleUpdateEntries.Count >= 1)
{
if (rootEntriesToSave.Count >= 2)
{
throw new InvalidOperationException(CosmosStrings.SaveChangesAutoTransactionBehaviorAlwaysTriggerAtomicity);
}
// There is only 1 entry, and it has a trigger
return new SaveGroups { BatchableUpdateEntries = [], SingleUpdateEntries = singleUpdateEntries };
}
var firstEntry = batchableEntries[0];
var key = new Grouping(firstEntry.CollectionId, _cosmosClient.GetPartitionKeyValue(firstEntry.Entry));
return batchableEntries.Count > 100
|| !batchableEntries.All(entry =>
entry.CollectionId == key.ContainerId && _cosmosClient.GetPartitionKeyValue(entry.Entry) == key.PartitionKeyValue)
? throw new InvalidOperationException(CosmosStrings.SaveChangesAutoTransactionBehaviorAlwaysAtomicity)
: new SaveGroups { BatchableUpdateEntries = [(key, batchableEntries)], SingleUpdateEntries = [] };
}
var batches = CreateBatches(batchableEntries);
// For bulk it is important that single writes are always classified as singleUpdateEntries so that they will be executed in parallelView on GitHub (pinned to 3a2006ef56)