hibernate/hibernate-orm · error · StaleObjectStateException
Row was already updated or deleted by another transaction
Error message
Row was already updated or deleted by another transaction
What it means
MergeCoordinatorTemporal handles merge for entities using temporal (system) versioning: it performs a 'row end' update on the old versioned row, then checks the row state. If the row-end update affected nothing but the row still exists, the row changed between being read and being merged, so a StaleObjectStateException is thrown for the entity and id - a classic optimistic-lock conflict surfacing during merge.
Source
Thrown at hibernate-core/src/main/java/org/hibernate/persister/entity/mutation/MergeCoordinatorTemporal.java:79
SharedSessionContractImplementor session) {
if ( entityPersister()
.excludedFromTemporalVersioning( dirtyAttributeIndexes, hasDirtyCollection ) ) {
return versionUpdateDelegate.update(
entity,
id,
rowId,
values,
oldVersion,
incomingOldValues,
dirtyAttributeIndexes,
hasDirtyCollection,
session
);
}
else {
final boolean rowEnded = performRowEndUpdate( entity, id, rowId, oldVersion, session );
if ( !rowEnded && currentRowExists( id, session ) ) {
throw new StaleObjectStateException( entityPersister().getEntityName(), id );
}
return entityPersister().getInsertCoordinator().insert( entity, id, values, session );
}
}
boolean performRowEndUpdate(
Object entity,
Object id,
Object rowId,
Object oldVersion,
SharedSessionContractImplementor session) {
class Result implements OperationResultChecker {
private boolean updated;
@Override
public boolean checkResult(PreparedStatementDetails statementDetails, int affectedRowCount, int batchPosition) {
updated = affectedRowCount > 0;
return !updated
|| resultCheck( id, statementDetails, affectedRowCount, batchPosition );View on GitHub (pinned to fad1729dce)
Solutions
- Catch StaleObjectStateException (JPA surfaces it as OptimisticLockException) around merge, reload fresh state, reapply the user's changes, and retry
- Shorten the read-to-merge window: carry a version/row-start token through the UI and reject stale edits early
- For hot entities, serialize edits with a pessimistic lock (SELECT ... FOR UPDATE) instead of retrying
- If delete-races are expected, check existence explicitly and handle the 'row gone' outcome instead of merging
Example fix
// before: merge with no conflict handling
session.merge(detachedOrder);
// after: retry on stale state
for (int attempt = 0; attempt < 3; attempt++) {
try {
session.merge(detachedOrder);
break;
}
catch (StaleObjectStateException e) { // OptimisticLockException in JPA
session.clear();
detachedOrder = reloadAndReapplyChanges(session, detachedOrder);
}
} Defensive patterns
Strategy: retry
Try / catch
try {
session.merge(detached);
tx.commit();
} catch (StaleObjectStateException e) { // surfaced as OptimisticLockException in JPA
session.clear();
detached = reloadAndReapplyChanges(session, detached);
// retry merge with fresh state
} Prevention
- Carry a version/row-token through long edit flows and reject stale edits before merge
- Wrap merge of concurrently-edited entities in a bounded retry loop
- Log entity + id on StaleObjectStateException to identify contended rows
When it happens
Trigger: session.merge(detachedEntity) on a temporally-versioned entity while another transaction updates or deletes the same row; long edit sessions where the detached state was read long before the merge; concurrent batch jobs upserting the same rows.
Common situations: Two users editing the same record in a web app; background schedulers racing user saves; retries after timeouts where the first request actually committed.
Related errors
- No rows were returned from JDBC query for versioned entity
- Newer version [" + latestVersion + "] of entity [" + infoStr
- %s for entity %s#%s
- <causeMessage> for entity [<entityName> with id '<id>']
- Entity '${persister.getEntityName()}' has no version and may
AI-assisted analysis of hibernate/hibernate-orm@fad1729dce (2026-08-22).
Data as JSON: /api/errors/77b9ad814d94f659.
Report an issue: GitHub.