Apex DML error

DUPLICATE_VALUE: duplicate value found

What “DUPLICATE_VALUE: duplicate value found” means and how to fix it

A unique field constraint produces this status code: a custom field or External Id marked Unique, checked exactly by the database. A duplicate rule produces a different one, DUPLICATES_DETECTED. The status code tells you which. In test suites this one is almost always a bulk insert colliding with itself.

Quick answer

If the message names a field and a record Id, it's a unique constraint; stop using constant values in test data and derive them from a counter or UserInfo.getOrganizationId(). If the status code is DUPLICATES_DETECTED instead, a duplicate rule refused the row; make the record less similar, or set DMLOptions.DuplicateRuleHeader.allowSave = true where saving a known duplicate is intentional and the rule only alerts.

What the error means, mechanically

A unique field is backed by a real uniqueness constraint. Before the row is committed, the platform checks the value against every other record of that object, including the other rows in the same DML statement. Any collision rejects the row:

text
Insert failed. First exception on row 0; first error:
  DUPLICATE_VALUE, duplicate value found: External_Id__c duplicates value
  on record with id: a015g00000ABCDeAAP

That trailing record Id is the most useful thing in the message: it points at the record you collided with. When the Id is missing or empty, the collision was against another row inside your own DML statement, which never got an Id.

Duplicate Management is a different system with its own status code: matching rules compare records fuzzily, and duplicate rules alert or block. From Apex both kinds refuse the save with DUPLICATES_DETECTED and the rule's alert text, never with DUPLICATE_VALUE. When the message says DUPLICATES_DETECTED, no unique field is involved, and searching for one wastes an afternoon.

Three properties of unique fields that surprise people:

  • Case-insensitive by default. A unique text field treats ACME and acme as the same value unless it's configured case-sensitive.
  • Nulls don't collide. Any number of records may leave a unique field blank; only populated values are compared.
  • External Id is orthogonal. Marking a field External Id does not make it unique; that's a separate checkbox. An External Id that isn't unique will happily accept duplicates until an upsert on it fails with a different error.

The causes, ranked by how often they happen

1. A bulk insert colliding with itself

The most common cause in test code by a wide margin. A loop builds 200 records to prove bulkification, and every one of them carries the same literal in a unique field. The first row saves, the second rejects, and the error blames a record Id that didn't exist a moment ago.

apex
// Fails on row 1: every record has the same External_Id__c
List<Product__c> products = new List<Product__c>();
for (Integer i = 0; i < 200; i++) {
  products.add(new Product__c(Name = 'Widget', External_Id__c = 'SKU-001'));
}
insert products;

// Works: derive the unique value from the loop index
for (Integer i = 0; i < 200; i++) {
  products.add(new Product__c(
    Name = 'Widget ' + i,
    External_Id__c = 'SKU-' + String.valueOf(i).leftPad(4, '0')
  ));
}
insert products;

2. Constant test data reused across methods and classes

Each test method runs in its own transaction and rolls back, so a constant is usually safe within a class. It stops being safe the moment a @testSetup creates the same value the test method creates, or a shared factory hands the same literal to two records in one transaction. Parallel execution doesn't cause this (rollback isolation still holds), but shared factories make it look like it does.

apex
// A factory that cannot collide with itself
public class TestDataFactory {
  private static Integer counter = 0;

  public static String uniqueKey(String prefix) {
    counter++;
    return prefix + '-' + String.valueOf(DateTime.now().getTime()) + '-' + counter;
  }

  public static Account newAccount() {
    return new Account(
      Name = 'Test Account ' + counter,
      External_Id__c = uniqueKey('ACC')
    );
  }
}

A static counter is enough inside one transaction. If you also need uniqueness against data already in the org (integration tests, or SeeAllData=true), mix in UserInfo.getOrganizationId() or a timestamp as above.

3. The value already exists in the org

Straightforward when the message names a record Id: query it and decide whether you meant to update rather than insert. upsert on the External Id field is usually the intended operation:

apex
// Insert-or-update keyed on the external system's identifier
List<Product__c> incoming = buildFromPayload(payload);
upsert incoming External_Id__c;

4. An upsert whose external Id matches more than one record

Rarer, and it carries a status code of its own: the upsert fails with DUPLICATE_EXTERNAL_ID because the key it was given matches two rows. That means the External Id field is not marked unique; fix the field definition rather than the code.

A duplicate rule reports DUPLICATES_DETECTED instead

Fuzzy matching on names, emails, or addresses. Test data made of near-identical records ("Test Account 1", "Test Account 2", all with the same billing address) is exactly what a standard matching rule is built to catch, and the save fails with DUPLICATES_DETECTED, a different status code from the one on this page. When saving the duplicate is intentional, say so explicitly rather than making the data weirder. The header lifts a rule whose action is Allow with an alert, as the three standard rules are; a Block rule still refuses the row:

apex
Database.DMLOptions dml = new Database.DMLOptions();
dml.DuplicateRuleHeader.AllowSave = true;

Database.SaveResult sr = Database.insert(acct, dml);
if (!sr.isSuccess()) {
  for (Database.Error e : sr.getErrors()) {
    System.debug(e.getStatusCode() + ': ' + e.getMessage());
  }
}

Partial success: saving the rows that are fine

Statement DML (insert records;) is all-or-nothing: one duplicate fails the entire list. When importing data, Database.insert(records, false) saves what it can and hands you a result per row, which also gives you a far better error report than the first exception on row 0.

apex
List<Database.SaveResult> results = Database.insert(records, false);

for (Integer i = 0; i < results.size(); i++) {
  if (results[i].isSuccess()) {
    continue;
  }
  for (Database.Error e : results[i].getErrors()) {
    if (e.getStatusCode() == StatusCode.DUPLICATE_VALUE) {
      System.debug('Row ' + i + ' duplicate on ' + e.getFields() + ': ' + e.getMessage());
    }
  }
}

e.getFields() is the fastest way to identify the colliding field programmatically, with no string parsing of the message.

Reproduce the unique-field collision locally

renmy runtime runs your Salesforce Apex tests on your machine against an embedded PostgreSQL with your project's schema, and it enforces unique fields the way the platform does. Fields your metadata marks unique are checked both against existing rows and within the same DML batch, case-insensitively for text, so the 200-identical-records bulk test fails locally with DUPLICATE_VALUE, duplicate value found in a DmlException, seconds after you write it, with no deploy.

Being precise about the scope: this covers causes 1 through 3, the ones that dominate test failures. For the other status code, renmy runtime also runs the duplicate and matching rules in your source and the three standard rules, and refuses a match with DUPLICATES_DETECTED and the rule's alert text, the status code a duplicate rule returns to Apex in an org. See Duplicate rules.

bash
# Reproduce the collision without a deploy
renmy test "ProductImportTest.testBulkInsert"

# See the failing DML and the operations that preceded it
renmy explain ProductImportTest.testBulkInsert