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

  1. Catch StaleObjectStateException (JPA surfaces it as OptimisticLockException) around merge, reload fresh state, reapply the user's changes, and retry
  2. Shorten the read-to-merge window: carry a version/row-start token through the UI and reject stale edits early
  3. For hot entities, serialize edits with a pessimistic lock (SELECT ... FOR UPDATE) instead of retrying
  4. 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

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


AI-assisted analysis of hibernate/hibernate-orm@fad1729dce (2026-08-22). Data as JSON: /api/errors/77b9ad814d94f659. Report an issue: GitHub.