После импорта старые строки остались на месте, а рядом появились их копии. Дело часто не в транзакции и не в скорости записи: BulkInsertOrUpdate просто не узнал существующие записи по первичному ключу.
Правило короткое. Если строка уже есть в базе, передавай её реальный Id. Для новой строки оставляй числовой Id равным 0 — но только когда модель EF Core и провайдер базы генерируют ключ при добавлении.
Как BulkInsertOrUpdate выбирает между вставкой и обновлением
BulkInsertOrUpdate сравнивает первичный ключ объекта с ключами строк в таблице. Нашёл совпадение — обновляет строку. Не нашёл — вставляет новую. Именно так поведение метода описано в документации EFCore.BulkExtensions.

Допустим, в таблице лежит товар с Id = 42. При импорте тот же товар пришёл с Id = 0, потому что ключ потерялся по дороге. Библиотека сама не станет искать совпадение по названию, артикулу или похожему полю. Для неё это новый объект.
На ревью интеграции проверяй четыре случая:
Id = 42, строка с таким ключом есть — библиотека обновит её;Id = 42, такого ключа нет — библиотека перейдёт к вставке, а результат будет зависеть от модели и базы;Id = 0, ключ генерирует база — появится новая строка с новым ключом;Id = 0, ключ должно назначить приложение — объект не готов к вставке.
Bulk-операция ускоряет запись, но не восстанавливает потерянные связи. В бенчмарке EFCore.BulkExtensions вставка 100 тысяч строк на SQL Server 2019 заняла 3 секунды, обычный EF справился за 11 секунд. Тест запускали на Intel Core i7-10510U, 16 ГБ DDR3 и SSD Samsung объёмом 512 ГБ. Это сравнение показывает выигрыш на крупном пакете, но ничего не говорит о правильности сопоставления строк.
Когда новый объект можно передать с Id = 0
EF Core по соглашению генерирует значения одиночных первичных ключей типов short, int, long и Guid, если приложение не передало ключ. Для числового первичного ключа SQL Server обычно создаёт столбец IDENTITY. Механика описана в документации Microsoft о генерируемых значениях EF Core.
Для такой модели новый объект можно создать так:
var customer = new Customer
{
Id = 0,
Name = "ООО Север"
};
Ноль — стандартное значение CLR для int. Он показывает, что приложение не задало ключ явно. При вставке ключ назначит база.
Но EF Core и EFCore.BulkExtensions отвечают за разные части операции. Документация Microsoft объясняет генерацию ключа в модели, а документация библиотеки — выбор между вставкой и обновлением. Одинаковое поведение identity-значений для всех версий пакета, провайдеров и сочетаний BulkConfig из этого не следует.
Поэтому правило «всем новым объектам ставим 0» опасно своей широтой. Пользуйся им лишь тогда, когда проверил генерацию ключа при добавлении на своей модели и СУБД.
Когда Id должно назначить приложение
При настройке через DatabaseGeneratedOption.None или ValueGeneratedNever база ключ не создаёт. Его должно присвоить приложение до вставки.

modelBuilder.Entity<Customer>()
.Property(x => x.Id)
.ValueGeneratedNever();
Здесь объект с Id = 0 — уже не заготовка для identity-вставки. У него просто нет ключа, который обязано назначить приложение.
Одного типа свойства для проверки мало. Посмотри настройку ключа в модели:
var property = dbContext.Model
.FindEntityType(typeof(Customer))
?.FindProperty(nameof);
var generation = property?.ValueGenerated;
Для нового числового ключа с генерацией при вставке ожидается ValueGenerated.OnAdd. Если модель возвращает Never, назначь ключ до bulk-операции.
С GUID механика иная. Провайдер EF Core для SQL Server умеет создавать последовательный GUID на стороне клиента. Поэтому заполненный GUID ещё не доказывает, что объект прочитан из базы: значение могло появиться сразу после создания сущности.
Смешанный список допустим, потерянные ключи — нет
В одном пакете могут лежать существующие сущности с заполненными ключами и новые сущности с Id = 0. Сам по себе такой список корректен.

Проблема начинается, когда ноль получают строки, которые уже существуют. BulkInsertOrUpdate отправит их в ветку вставки. При следующем импорте они снова придут без локального ключа — и таблица снова вырастет, если операцию не остановит другое ограничение.
Признак Id != 0 тоже не доказывает, что строка существует. Внешняя система может прислать Id = 9001, которого нет в локальной таблице. Библиотека при отсутствии совпадения выберет вставку, но общий результат для явного значения в identity-столбце документация не определяет. На него влияют SQL Server, параметры bulk-операции и версия пакета.
Проверь этот сценарий отдельно — на той же СУБД и версии EFCore.BulkExtensions, что стоят в продакшене.
Если входные данные не содержат локального первичного ключа, остаются два рабочих пути:
- прочитать соответствия между внешним стабильным ключом и локальным
Id, а затем заполнить ключи существующих объектов; - настроить подтверждённый уникальный ключ для сопоставления вместо первичного, если нужную конфигурацию поддерживают твои версия EFCore.BulkExtensions и провайдер.
Название клиента или адрес электронной почты без ограничения уникальности не годятся. Две одинаковые строки сделают обновление неоднозначным.
Провайдер базы меняет механику вставки
EFCore.BulkExtensions работает с каждой СУБД через её собственные средства:
- SQL Server —
SqlBulkCopyиMERGE; - PostgreSQL 9.5 и новее —
COPY BINARYиON CONFLICT; - MySQL 8 и новее —
MySqlBulkCopyиON DUPLICATE; - SQLite — обычный SQL и
UPSERT.
Эти механизмы перечислены в документации EFCore.BulkExtensions. Настройка ValueGeneratedOnAdd определяет, когда генерируется значение, но не задаёт сам способ генерации. Его выбирает провайдер EF Core — Microsoft оговаривает это отдельно.
Значит, вывод про IDENTITY относится к SQL Server. Не переноси его без теста на PostgreSQL, MySQL или SQLite. Даже на SQL Server поведение и скорость зависят не только от bulk-вставки: проблемы параллелизма отдельно разобраны в материале про работу 1С с SQL Server.
Что проверить перед рабочим импортом
Прогони четыре теста на реляционной тестовой базе:
- Передай существующую запись с её реальным первичным ключом. Убедись, что изменилась та же строка.
- Передай новый объект со стандартным значением ключа. Проверь, что после вставки он получил новый
Id. - Передай объект с ненулевым ключом, которого нет в таблице. Зафиксируй результат своей конфигурации: вставка или ошибка.
- Отправь тот же пакет повторно. Число строк не должно увеличиться.
UseInMemoryDatabase здесь не поможет: InMemory-провайдер не поддерживает реляционные методы EFCore.BulkExtensions. Используй контейнер или отдельную тестовую базу той же СУБД, что работает в продакшене.
Оставь в ревью одно правило: существующим строкам передавай первичный ключ из базы; новым — стандартное значение, если генерация ключа подтверждена; записи без стабильного ключа сначала сопоставляй и лишь затем отправляй в BulkInsertOrUpdate.