hibernate/hibernate-orm · error · StaleStateException
Unexpected row count (expected row count {} but was {}) [{}]
Error message
Unexpected row count (expected row count {} but was {}) [{}] What it means
checkNonBatched throws StaleStateException with expected-vs-actual counts and the SQL when a non-batched update/delete affects fewer rows than expected - classically zero. The ORM expected to change a row that is no longer there (or whose version no longer matches), which almost always means concurrent modification or deletion outside the current session.
Source
Thrown at hibernate-core/src/main/java/org/hibernate/jdbc/Expectations.java:91
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"
+ actualVsExpected( expectedRowCount, rowCount )
+ " [" + sql + "]"
);
}
if ( expectedRowCount < rowCount ) {
throw new TooManyRowsAffectedException(
"Unexpected row count"
+ actualVsExpected( expectedRowCount, rowCount ),
1, rowCount
);
}
}
private static String actualVsExpected(int expectedRowCount, int rowCount) {
return " (expected row count " + expectedRowCount + " but was " + rowCount + ")";
}
View on GitHub (pinned to fad1729dce)
Solutions
- Before deleting/updating, verify presence with a fresh find()/findById and skip gracefully if gone
- Catch StaleStateException, clear the persistence context, reload, and decide: retry, merge, or report a conflict
- If rows can legitimately vanish, mark the operation with @Expectation(none) or use a direct bulk JPQL delete with no count check
Example fix
// before
Person p = em.getReference(Person.class, id);
em.remove(p); em.flush(); // StaleStateException: row already gone
// after
Person p = em.find(Person.class, id); // null-safe hit check
if (p != null) { em.remove(p); em.flush(); }
// else: treat as already deleted - no error Defensive patterns
Strategy: try-catch
Validate before calling
if (em.find(Person.class, id) == null) {
// row already gone - skip the delete instead of flushing into StaleStateException
return;
} Try / catch
try {
em.remove(target);
em.flush();
} catch (org.hibernate.StaleStateException e) {
em.clear();
if (em.find(Person.class, id) == null) {
// concurrent delete won - treat as success or report conflict
} else {
// version conflict - reload, reapply, retry once
}
} Prevention
- Check existence with find() before remove() on entities that others may delete
- Avoid holding detached entities across long user think-time
- Investigate out-of-band SQL jobs that delete rows the ORM still references
When it happens
Trigger: session.delete()/remove() on an entity whose row was already deleted by another transaction or by direct SQL; versioned update where the version column changed; @SQLUpdate returning 0 rows; flush of a detached entity after the row was archived.
Common situations: Two sessions or services editing the same row; DBA/cleanup jobs deleting rows while the app holds entities in memory; retry logic re-running a delete; replica lag causing reads of rows already gone from the primary.
Related errors
- %s for entity %s#%s
- <causeMessage> for entity [<entityName> with id '<id>']
- Batch update returned unexpected row count from update {} (e
- 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/b4d3485cd6e3d2f9.
Report an issue: GitHub.