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

  1. Split the SaveChanges call so that triggered root entities are saved one at a time (one root per SaveChanges).
  2. Revert AutoTransactionBehavior to the default (Never) for batches that mix triggered and non-triggered entities.
  3. 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

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


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 parallel

View on GitHub (pinned to 3a2006ef56)