Repository navigation
BulkInsertAsync: TableName/Schema override is not applied to separately mapped owned entities (child rows go to the model's table) #76
Description
Summary
IBulkInsertOptions.TableName / Schema redirect the parent table only. Owned entities that are mapped to their own table (OwnsOne(...).ToTable(...)) are always bulk-copied into the table name taken from the EF model, so parent and child rows can end up in different places. There is no option to redirect the child table.
If the model's child table exists, the child rows are written there silently (wrong data, no exception). If it does not exist, the parent rows have already been written and the child insert throws (partial write).
Environment
- Thinktecture.EntityFrameworkCore.SqlServer 10.4.0
- Microsoft.EntityFrameworkCore.SqlServer 10.0.9
- SQL Server (container,
mcr.microsoft.com/mssql/server), .NET 10
Repro
public class Order
{
public int Id { get; set; }
public string? Name { get; set; }
public OrderLine Line { get; set; } = new();
}
public class OrderLine
{
public int Qty { get; set; }
}
public class Db(string connectionString) : DbContext
{
protected override void OnConfiguring(DbContextOptionsBuilder options) =>
options.UseSqlServer(connectionString, o => o.AddBulkOperationSupport());
protected override void OnModelCreating(ModelBuilder modelBuilder) =>
modelBuilder.Entity<Order>(b =>
{
b.ToTable("Orders");
b.HasKey(x => x.Id);
b.Property(x => x.Id).ValueGeneratedNever();
b.OwnsOne(x => x.Line, o => o.ToTable("OrderLines")); // separate table
});
}CREATE TABLE Orders (Id int NOT NULL PRIMARY KEY, Name nvarchar(50) NULL);
CREATE TABLE OrderLines (OrderId int NOT NULL PRIMARY KEY, Qty int NOT NULL);
CREATE TABLE Orders_Archive (Id int NOT NULL PRIMARY KEY, Name nvarchar(50) NULL);
CREATE TABLE OrderLines_Archive(OrderId int NOT NULL PRIMARY KEY, Qty int NOT NULL);await db.BulkInsertAsync(
[new Order { Id = 1, Name = "o1", Line = new() { Qty = 5 } }],
new SqlServerBulkInsertOptions { TableName = "Orders_Archive" });Expected
Both parent and child rows go to the redirected tables (or there is a way to redirect the child table as well), or an exception is thrown if the override cannot be applied consistently.
Actual
Orders rows = 0
OrderLines rows = 1 <- child row written to the model's table
Orders_Archive rows = 1
OrderLines_Archive rows = 0
No exception. If OrderLines does not exist, the child SqlBulkCopy fails with Invalid object name after the parent row has been written.
Cause
SqlServerBulkOperationExecutor.BulkInsertSeparatedOwnedEntitiesAsync reads the child table straight from the metadata and ignores the options:
var childTableName = childContext.EntityType.GetTableName()
?? throw new InvalidOperationException($"The entity '{childContext.EntityType.Name}' has no table name.");
numberOfInsertedRows += await BulkInsertAsync(childContext.Entities,
childContext.EntityType.GetSchema(),
childTableName,
childContext,
cancellationToken);The parent goes through BulkOperationModelExtensions.GetEntityType(model, options.TableName, options.Schema), the children do not. Schema has the same problem. From reading the source, the Npgsql and Sqlite executors use the same pattern (childContext.EntityType.GetTableName()); I only ran the SQL Server one.
Why this matters
The table name is only known at run time in several setups (table-per-tenant, sharded tables, archive tables). SqlBulkCopy does not go through EF Core command interceptors, so the only hook is the TableName option, and it does not reach the child tables.
Suggestions
-
Minimum: fail fast. If
TableNameorSchemais set and the entity type has separately mapped owned types, throw instead of silently writing the children to another table. -
Better: a way to redirect child tables as well, e.g. a resolver on the options that is used for the parent and for every owned child, which would also cover the temp tables of the MERGE operations:
Func<IEntityType, (string? Schema, string TableName)>? TableNameResolver { get; set; }
Workaround
Do not use separately mapped owned types with BulkInsertAsync when overriding the table name. Model the child as a regular entity and call BulkInsertAsync once per entity type, each with its own TableName, inside a transaction.
Activity
fix: resolve all bulk operation tables through IBulkOperationTableNam…
PawelGerr commentedon Oct 12, 2026
Thanks for the detailed report. The fix is available in the pre-release 10.5.0-beta01.
The new option
TableNameProvider(IBulkOperationTableNameProvider) is called for the root entity type and for every owned type that is mapped to a separate table, so parent and child rows go to the redirected tables.IBulkOperationTableNameProvider.Create(entityType => ...)covers the delegate-based variant from your suggestion.TableNameandSchemaare obsolete now and throw when the entity has owned types in separate tables.The release notes list the breaking changes and the migration steps. I would appreciate it if you could verify the beta with your table-per-tenant and archive scenarios before I publish 10.5.0.
pengweiqhca commentedon Oct 13, 2026
Thanks! Verified and working fine.
PawelGerr commentedon Oct 14, 2026
10.5.0 is released