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

  1. Before deleting/updating, verify presence with a fresh find()/findById and skip gracefully if gone
  2. Catch StaleStateException, clear the persistence context, reload, and decide: retry, merge, or report a conflict
  3. 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

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


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