hibernate/hibernate-orm · error · StaleStateException
Batch update returned unexpected row count from update {} (e
Error message
Batch update returned unexpected row count from update {} (expected row count {} but was {}) [{}] What it means
In batched mode, when a statement reports fewer affected rows than expected (expected > actual, typically 0 for a versioned update), Hibernate throws StaleStateException with the batch position, expected-vs-actual counts, and the SQL. The database simply did not change the rows the ORM believed it should - usually because the row was already modified or deleted by another transaction.
Source
Thrown at hibernate-core/src/main/java/org/hibernate/jdbc/Expectations.java:74
if ( statement instanceof CallableStatement callableStatement ) {
return callableStatement;
}
else {
throw new HibernateException( "Expectation.OutParameter operates exclusively on CallableStatements: "
+ statement.getClass() );
}
}
static void checkBatched(int expectedRowCount, int rowCount, int batchPosition, String sql) {
switch (rowCount) {
case EXECUTE_FAILED:
throw new BatchFailedException( "Batch update failed: " + batchPosition );
case SUCCESS_NO_INFO:
BATCH_MESSAGE_LOGGER.batchSuccessUnknown( batchPosition );
break;
default:
if ( expectedRowCount > rowCount ) {
throw new StaleStateException(
"Batch update returned unexpected row count from update " + batchPosition
+ actualVsExpected( expectedRowCount, rowCount )
+ " [" + sql + "]"
);
}
else if ( expectedRowCount < rowCount ) {
throw new BatchedTooManyRowsAffectedException(
"Batch update returned unexpected row count from update " + batchPosition
+ actualVsExpected( expectedRowCount, rowCount ),
expectedRowCount, rowCount, batchPosition );
}
}
}
static void checkNonBatched(int expectedRowCount, int rowCount, String sql) {
if ( expectedRowCount > rowCount ) {
throw new StaleStateException(
"Unexpected row count"View on GitHub (pinned to fad1729dce)
Solutions
- Confirm the row still exists and its version matches before flushing (or re-attach with a fresh load)
- Catch StaleStateException / OptimisticLockException, reload the entity, reapply changes, retry the unit of work
- If the update legitimately affects 0 rows, use @Expectation(none) or adjust the custom SQL counts
- Audit for out-of-band deletes (DBA scripts, cascade jobs) running against the same tables
Example fix
// before
session.merge(detachedPerson);
session.flush(); // StaleStateException: row version changed / row gone
// after
Person fresh = session.find(Person.class, detachedPerson.getId());
if (fresh != null) { fresh.applyChangesFrom(detachedPerson); session.flush(); }
else { throw new ConcurrentModificationException("person deleted concurrently"); } Defensive patterns
Strategy: try-catch
Validate before calling
// optional pre-flight for delete-heavy flows
Object version = em.createQuery("select p.version from Person p where p.id = :id", Object.class)
.setParameter("id", id)
.getSingleResultOrNull();
if (version == null) throw new IllegalStateException("row missing before update/delete"); Try / catch
try {
em.flush();
} catch (org.hibernate.StaleStateException e) {
em.clear();
Person fresh = em.find(Person.class, id);
if (fresh == null) {
// row deleted elsewhere - treat as gone, do not retry the same operation
} else {
// reload current state, reapply user changes, retry once
}
} Prevention
- Keep transactions short to narrow the window for concurrent row loss
- Prefer em.find() presence checks over getReference() before delete
- Surface optimistic-lock conflicts to users instead of silently retrying deletes
When it happens
Trigger: Versioned (optimistic-lock) batched updates where the version no longer matches; session.delete() of an entity whose row was concurrently removed; custom @SQLUpdate returning 0; batched delete hitting zero rows.
Common situations: Concurrent modifications from another session/service; manual deletes/updates via SQL behind the ORM's back; long-lived detached objects flushed after external changes; load balancers routing two users onto the same row.
Related errors
- %s for entity %s#%s
- <causeMessage> for entity [<entityName> with id '<id>']
- Unexpected row count (expected row count {} but was {}) [{}]
- Newer version [" + latestVersion + "] of entity [" + infoStr
- No rows were returned from JDBC query for versioned entity
AI-assisted analysis of hibernate/hibernate-orm@fad1729dce (2026-08-22).
Data as JSON: /api/errors/45657987c808fb36.
Report an issue: GitHub.