hibernate/hibernate-orm · error · StaleObjectStateException

<causeMessage> for entity [<entityName> with id '<id>']

Error message

<causeMessage> for entity [<entityName> with id '<id>']

What it means

ModelMutationHelper executes insert/update/delete mutations and catches StaleStateException; when the mutating table is not optional and the JDBC driver reported 0 affected rows, it throws StaleObjectStateException(fullPath, id, cause). That exception's getMessage() composes '<causeMessage> for entity [EntityName with id '<id>']' — this is Hibernate's optimistic-locking conflict: the row was updated or deleted by another transaction since it was loaded.

Source

Thrown at hibernate-core/src/main/java/org/hibernate/engine/jdbc/mutation/internal/ModelMutationHelper.java:72

			Object id,
			SessionFactoryImplementor sessionFactory) {
		try {
			statementDetails.getExpectation().verifyOutcome(
					affectedRowCount,
					statementDetails.getStatement(),
					batchPosition,
					statementDetails.getSqlString()
			);
			return true;
		}
		catch (StaleStateException e) {
			if ( !statementDetails.getMutatingTableDetails().isOptional() && affectedRowCount == 0 ) {
				final String fullPath = mutationTarget.getNavigableRole().getFullPath();
				final var statistics = sessionFactory.getStatistics();
				if ( statistics.isStatisticsEnabled() ) {
					statistics.optimisticFailure( fullPath );
				}
				throw new StaleObjectStateException( fullPath, id, e );
			}
			return false;
		}
		catch (TooManyRowsAffectedException e) {
			throw new HibernateException(
					String.format(
							Locale.ROOT,
							"Duplicate identifier in table (%s) - %s#%s",
							statementDetails.getMutatingTableDetails().getTableName(),
							mutationTarget.getNavigableRole().getFullPath(),
							id
					)
			);
		}
		catch (Throwable t) {
			return false;
		}
	}

View on GitHub (pinned to fad1729dce)

Solutions

  1. Catch OptimisticLockException/StaleObjectStateException at the use-case boundary and handle the conflict: reload the entity and retry, or report 409/conflict to the user
  2. Verify @Version optimistic-locking is mapped to the actual DB column and that all writers update it
  3. With assigned identifiers, check unsaved-value configuration so existing rows are updated, not treated inconsistently
  4. Shorten conversations: load-modify-save within one transaction instead of holding entities across requests

Example fix

// before: unguarded merge
em.merge(doc);

// after: explicit conflict handling
try {
    em.merge(doc);
    em.flush();
}
catch (OptimisticLockException e) {
    Doc fresh = em.find(Doc.class, doc.getId()); // reload and reapply or report conflict
    throw new ConcurrentModificationException("doc " + doc.getId() + " changed", e);
}
Defensive patterns

Strategy: retry

Validate before calling

// optimistic guard: check the version before flushing changes
Doc fresh = em.find(Doc.class, doc.getId());
if (fresh == null || fresh.getVersion() > doc.getVersion()) {
    throw new ConcurrentModificationException("doc changed, reload before edit");
}

Try / catch

catch (OptimisticLockException e) {
    // reload latest state, reapply user changes, retry once with a backoff
}

Prevention

When it happens

Trigger: flush()/commit of an UPDATE or DELETE whose WHERE (id [+ version]) matched no row: a concurrent transaction committed changes first, the row was deleted, or the version/unsaved-value mapping is wrong so Hibernate compares stale criteria.

Common situations: Two users editing the same record; background jobs deleting entities out from under a loaded screen; long conversations with detached entities merged after the data changed; missing or misconfigured @Version columns.

Related errors


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