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
Select-based pessimistic locking (AbstractSelectLockingStrategy) runs a 'select ... for update'-style statement and expects exactly one row back. When resultSet.next() returns false the row was not visible - deleted by a concurrent transaction, never committed, or the id/version simply does not exist - and Hibernate throws StaleObjectStateException, whose built-in message is 'Row was already updated or deleted by another transaction'. Statistics (optimisticFailure) are bumped when enabled.
Source
Thrown at hibernate-core/src/main/java/org/hibernate/dialect/lock/AbstractSelectLockingStrategy.java:108
try {
lockable.getIdentifierType().nullSafeSet( preparedStatement, id, 1, session );
if ( lockable.isVersioned() ) {
lockable.getVersionType().nullSafeSet(
preparedStatement,
version,
lockable.getIdentifierType().getColumnSpan( factory.getRuntimeMetamodels() ) + 1,
session
);
}
final var resultSet = jdbcCoordinator.getResultSetReturn().extract( preparedStatement, sql );
try {
if ( !resultSet.next() ) {
final var statistics = factory.getStatistics();
if ( statistics.isStatisticsEnabled() ) {
statistics.optimisticFailure( lockable.getEntityName() );
}
throw new StaleObjectStateException( lockable.getEntityName(), id );
}
}
finally {
jdbcCoordinator.getLogicalConnection().getResourceRegistry().release( resultSet, preparedStatement );
}
}
finally {
jdbcCoordinator.getLogicalConnection().getResourceRegistry().release( preparedStatement );
jdbcCoordinator.afterStatementExecution();
}
}
catch ( SQLException sqle ) {
throw convertException( object, jdbcException( id, session, sqle, sql ) );
}
}
private JDBCException jdbcException(Object id, SharedSessionContractImplementor session, SQLException sqle, String sql) {
return session.getJdbcServices().getSqlExceptionHelper()View on GitHub (pinned to fad1729dce)
Solutions
- Catch StaleObjectStateException and treat it as 'entity no longer exists' - reload and either skip, recreate, or surface a conflict to the user
- Verify existence right before locking when deletes are common (or re-check after the exception using session.find)
- Coordinate delete flows and lock flows so they cannot interleave on the same rows
- Log the entity name and identifier from the exception to identify which row vanished
Example fix
// before
session.lock(orderLine, LockMode.PESSIMISTIC_WRITE);
// after
try {
session.lock(orderLine, LockMode.PESSIMISTIC_WRITE);
}
catch (StaleObjectStateException e) {
OrderLine fresh = session.find(OrderLine.class, orderLine.getId());
if (fresh == null) {
// row deleted concurrently: skip or re-create
}
} Defensive patterns
Strategy: try-catch
Validate before calling
// Optional pre-check when deletes are expected
Object fresh = session.find(entityClass, id);
if (fresh == null) {
// skip locking - row already gone
} Try / catch
try {
session.buildLockRequest(new LockOptions(LockMode.PESSIMISTIC_WRITE)).lock(entity);
}
catch (StaleObjectStateException e) {
// row not visible: deleted or never committed - verify and handle
if (session.find(entityClass, id) == null) {
// treat as deleted: skip or compensate
}
else {
// visible now (uncommitted earlier): retry the lock once
}
} Prevention
- Re-verify existence after a failed select-based lock instead of assuming corruption
- Coordinate delete flows and lock flows so they do not race on the same rows
- Include entity name + id from the exception in logs to trace which row disappeared
When it happens
Trigger: session.lock(entity, LockMode.PESSIMISTIC_WRITE) on an entity whose row another transaction has deleted or not yet committed; locking a detached entity whose id was removed; read-uncommitted/read-committed isolation hiding an uncommitted insert; wrong id passed after a merge. The select returns an empty result set and the exception carries entityName and id.
Common situations: Locking entities referenced by stale foreign keys (row already gone); concurrent delete flows racing lock flows; tests that lock rolled-back fixtures; isolation-level differences between environments making rows invisible.
Related errors
- Row was already updated or deleted by another transaction
- %s for entity %s#%s
- Could not obtain pessimistic lock
- <causeMessage> for entity [<entityName> with id '<id>']
- WRITE is not a valid LockMode as an argument
AI-assisted analysis of hibernate/hibernate-orm@fad1729dce (2026-08-22).
Data as JSON: /api/errors/f48514c95e82efd1.
Report an issue: GitHub.