In one sentence: your load contained two rows that resolve to the same record, or it wrote a value that a unique field already holds somewhere else. On an upsert the first case is by far the more common, and the message names the offending value: Duplicate external id specified: ACME-0042.
The two error strings are different problems with the same name:
DUPLICATE_VALUE: Duplicate external id specified: ACME-0042
DUPLICATE_VALUE: duplicate value found: External_Id__c
duplicates value on record with id: a01Ab000001XyzA
The first says two rows in the batch you just submitted carry the same external ID. The second says one row collided with a record that already exists.
Why the platform refuses instead of picking one
An upsert is a match-then-write. When two source rows carry ACME-0042, the platform has two candidate field sets for one target record and no rule for choosing between them. Applying both in sequence would make the result depend on row order inside a batch, and batch ordering is not something you control. So it fails the rows and tells you the value.
The same reasoning applies to a unique field that is not the match key. If your external ID is Legacy_Id__c but Invoice_Number__c is also marked Unique, two rows with the same invoice number fail on the second field even though they matched different records.
Where the duplicates come from
- A join that fanned out. The extract query joined to a child table and produced one row per child. Every parent now appears several times with the same key.
- Case. A unique text field has a “treat ABC and abc as duplicate values” option. When it is set,
acme-0042andACME-0042are the same key and collide. When it is not set, they are two different records, which is usually worse because nobody notices. - Whitespace. A trailing space or a non-breaking space from a spreadsheet export makes two keys that look identical in the file and behave differently in the load.
- Incremental extracts that overlap. A “changed since” window run twice, or two files concatenated, both containing the same record.
- A delta file appended to a full file. Common when someone reruns an export to catch a late change.
De-duplicate before you load
This is the fix. Not error handling, not retrying, not splitting the batch. Decide which row wins and send one row per key.
If the file is small enough for a shell, the check takes seconds:
cut -d, -f1 records.csv | sort | uniq -d
That prints every key that appears more than once. Anything it prints will fail. The rule for which row survives is a business decision: usually the one with the latest modified timestamp, sometimes the one with the most fields populated. Pick it deliberately rather than letting row order pick it for you.
In Apex, a map keyed on the external ID does the same job and is the standard pattern when you are building the records yourself:
Map<String, Account> byKey = new Map<String, Account>();
for (Account a : incoming) {
byKey.put(a.Legacy_Id__c, a); // later row wins
}
upsert byKey.values() Legacy_Id__c;
Whether the later row should win is the part worth thinking about. If it should not, sort the list first or compare before putting.
Blank keys behave differently
A row with a null external ID is treated as an insert, and null does not violate a unique constraint. So a file with fifty blank keys will not throw a duplicate error. It will create fifty new records, which is almost never what the person running the load expected. Check for blanks in the same pass as duplicates.
When the collision is with existing data
The second message form gives you the record ID. Query it before deciding anything:
SELECT Id, Name, External_Id__c, CreatedDate, CreatedBy.Name
FROM Account WHERE Id = 'a01Ab000001XyzA'
If it was created by an earlier run of the same integration, you have a load that inserted where it should have upserted. If it was created by a user in the UI, you have a genuine duplicate record and the fix is a merge, not a code change.
Frequently asked
Does Database.upsert(records, field, false) avoid it? It stops the good rows failing alongside the bad ones, and it gives you per-row results to log. The duplicate rows still fail. Partial success is a reporting improvement, not a fix.
Can I just split the duplicates across two batches? They will both succeed and the second will overwrite the first. You have silently chosen a winner at random. With parallel Bulk API loads you may also get row lock failures, because two batches are writing the same record at once.
Why did this work last month? Because last month’s extract happened to contain no duplicates. Nothing changed in the org. The check belongs in the extract, where the duplicates are created.
Is a unique field the same as a duplicate rule? No. Unique is a database constraint and always fires. Duplicate rules are configurable matching that can be bypassed, and they raise a different error.
