{"record":{"id":"45657987c808fb36","repo":"hibernate/hibernate-orm","slug":"batch-update-returned-unexpected-row-count-from-up","errorCode":null,"errorMessage":"Batch update returned unexpected row count from update {} (expected row count {} but was {}) [{}]","messagePattern":"Batch update returned unexpected row count from update (.+?) \\(expected row count (.+?) but was (.+?)\\) \\[(.+?)\\]","errorType":"exception","errorClass":"StaleStateException","httpStatus":null,"severity":"error","filePath":"hibernate-core/src/main/java/org/hibernate/jdbc/Expectations.java","lineNumber":74,"sourceCode":"\t\tif ( statement instanceof CallableStatement callableStatement ) {\n\t\t\treturn callableStatement;\n\t\t}\n\t\telse {\n\t\t\tthrow new HibernateException( \"Expectation.OutParameter operates exclusively on CallableStatements: \"\n\t\t\t\t\t+ statement.getClass() );\n\t\t}\n\t}\n\n\tstatic void checkBatched(int expectedRowCount, int rowCount, int batchPosition, String sql) {\n\t\tswitch (rowCount) {\n\t\t\tcase EXECUTE_FAILED:\n\t\t\t\tthrow new BatchFailedException( \"Batch update failed: \" + batchPosition );\n\t\t\tcase SUCCESS_NO_INFO:\n\t\t\t\tBATCH_MESSAGE_LOGGER.batchSuccessUnknown( batchPosition );\n\t\t\t\tbreak;\n\t\t\tdefault:\n\t\t\t\tif ( expectedRowCount > rowCount ) {\n\t\t\t\t\tthrow new StaleStateException(\n\t\t\t\t\t\t\t\"Batch update returned unexpected row count from update \" + batchPosition\n\t\t\t\t\t\t\t\t\t+ actualVsExpected( expectedRowCount, rowCount )\n\t\t\t\t\t\t\t\t\t+ \" [\" + sql + \"]\"\n\t\t\t\t\t);\n\t\t\t\t}\n\t\t\t\telse if ( expectedRowCount < rowCount ) {\n\t\t\t\t\tthrow new BatchedTooManyRowsAffectedException(\n\t\t\t\t\t\t\t\"Batch update returned unexpected row count from update \" + batchPosition\n\t\t\t\t\t\t\t\t\t+ actualVsExpected( expectedRowCount, rowCount ),\n\t\t\t\t\t\t\texpectedRowCount, rowCount, batchPosition );\n\t\t\t\t}\n\t\t}\n\t}\n\n\tstatic void checkNonBatched(int expectedRowCount, int rowCount, String sql) {\n\t\tif ( expectedRowCount > rowCount ) {\n\t\t\tthrow new StaleStateException(\n\t\t\t\t\t\"Unexpected row count\"","sourceCodeStart":56,"sourceCodeEnd":92,"githubUrl":"https://github.com/hibernate/hibernate-orm/blob/fad1729dce015f908198d57a8d80274a30f905a5/hibernate-core/src/main/java/org/hibernate/jdbc/Expectations.java#L56-L92","documentation":"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.","triggerScenarios":"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.","commonSituations":"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.","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"],"exampleFix":"// before\nsession.merge(detachedPerson);\nsession.flush(); // StaleStateException: row version changed / row gone\n\n// after\nPerson fresh = session.find(Person.class, detachedPerson.getId());\nif (fresh != null) { fresh.applyChangesFrom(detachedPerson); session.flush(); }\nelse { throw new ConcurrentModificationException(\"person deleted concurrently\"); }","handlingStrategy":"try-catch","validationCode":"// optional pre-flight for delete-heavy flows\nObject version = em.createQuery(\"select p.version from Person p where p.id = :id\", Object.class)\n                  .setParameter(\"id\", id)\n                  .getSingleResultOrNull();\nif (version == null) throw new IllegalStateException(\"row missing before update/delete\");","typeGuard":null,"tryCatchPattern":"try {\n    em.flush();\n} catch (org.hibernate.StaleStateException e) {\n    em.clear();\n    Person fresh = em.find(Person.class, id);\n    if (fresh == null) {\n        // row deleted elsewhere - treat as gone, do not retry the same operation\n    } else {\n        // reload current state, reapply user changes, retry once\n    }\n}","preventionTips":["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"],"tags":["optimistic-locking","concurrency","batching","flush"],"backgroundTag":"optimistic-lock-stale-state","analyzedSha":"fad1729dce015f908198d57a8d80274a30f905a5","analyzedAt":"2026-08-22T04:13:57.527Z","schemaVersion":2},"datasetVersion":"2026-08-22T09:17:25.309Z"}