halo-dev/halo · error · DuplicateKeyException
Duplicate key '{}' for index '{}'
Error message
Duplicate key '{}' for index '{}' What it means
Thrown as org.springframework.dao.DuplicateKeyException in the prepare() preflight of SingleValueIndex.UpsertTransactionalOperation. Before any mutation, it checks whether the index is unique, the new key differs from the row's previous key, and the new key already maps to other primary keys. If so it aborts the transaction before commit, so no partial state is written.
Source
Thrown at application/src/main/java/run/halo/app/extension/index/SingleValueIndex.java:281
private boolean committed;
UpsertTransactionalOperation(String primaryKey, @Nullable K newKey) {
this.primaryKey = primaryKey;
this.newKey = newKey;
}
@Override
public void prepare() {
// preflight checks
if (!spec.isNullable() && newKey == null) {
throw new IllegalArgumentException("Index %s of %s is not nullable".formatted(getName(), primaryKey));
}
previousKey = invertedIndex.get(primaryKey);
previousNull = nullKeyValues.contains(primaryKey);
if (isUnique() && newKey != null && !Objects.equals(previousKey, newKey)) {
var existingPrimaryKeys = index.get(newKey);
if (!CollectionUtils.isEmpty(existingPrimaryKeys)) {
throw new DuplicateKeyException("Duplicate key '" + newKey + "' for index '" + getName() + "'");
}
}
}
@Override
public void commit() {
if (committed) {
return;
}
committed = true;
removeKey(primaryKey, previousKey);
addKey(primaryKey, newKey);
}
@Override
public void rollback() {
if (!committed) {
return;View on GitHub (pinned to d2f5165f9c)
Solutions
- Resolve the collision: choose another value, or delete/update the owner of the existing key first.
- Guard the update with a pre-check via the indexer/query API for the candidate key before attempting the write.
- Verify the extension's index schema really should be unique; if not, relax it at the schema level.
- Retry with exponential backoff only if the collision is from a concurrent transient state you intend to clear.
Example fix
// before
extensionClient.update(resource).block(); // newKey collides
// after
boolean taken = queryIndex.equal("fieldName", candidateValue).stream().anyMatch(id -> !id.equals(resource.getMetadata().getName()));
if (taken) {
throw new IllegalStateException("Value already in use: " + candidateValue);
}
extensionClient.update(resource).block(); Defensive patterns
Strategy: validation
Validate before calling
// Preflight: is the new single-value key free for someone other than self?
Set<String> existing = valueIndexQuery.equal("<field>", newKey);
boolean collision = !existing.isEmpty() && !existing.equals(Set.of(selfPrimaryKey));
if (collision) throw new IllegalStateException("Key taken: " + newKey); Try / catch
try {
indexer.update(...); // goes through prepare()->commit()
} catch (DuplicateKeyException e) {
throw new ConflictException("Rename target already in use", e);
} Prevention
- Treat prepare() DuplicateKeyException as recoverable: re-read and prompt for a new value.
- Avoid renaming uniquely-indexed fields in bulk without per-row collision checks.
- Keep the prepare/commit transaction boundary intact; don't bypass it.
When it happens
Trigger: Updating a single-value unique index to a new value that another resource already owns: isUnique()==true, newKey != null, !Objects.equals(previousKey, newKey), and index.get(newKey) returns a non-empty primary-key set.
Common situations: Renaming a uniquely-indexed field (e.g. username, slug) to a value already in use; bulk re-import that assigns colliding keys; concurrent edits where two requests target the same free value.
Related errors
AI-assisted analysis of halo-dev/halo@d2f5165f9c (2026-08-14).
Data as JSON: /api/errors/ff5e784363e6b87b.
Report an issue: GitHub.