Multiple updates with nested numeric value inside JSON column

I’m encountering a multiple update issue in a project that uses Hibernate and Spring, where Spring Data JPA is being used with these dependencies:

Hibernate 6.6.26

Spring Boot 3.5.7
Spring Cloud 2025.0.0
Spring Cloud GCP 7.4.0

When saving an entity that has a JSON column containing a serialized object that has a numeric field, multiple updates get executed on the saved entity (resulting in the optimistic locking field ‘version’ being incremented to number 2).
The issue doesn’t arise when the numeric field isn’t present inside the serialized object saved inside JSON column, resulting in the version column having a value of 0.
Using the ‘save’ method instead of the ‘saveAndFlush’ results in just 1 update and the version column having a value of 1.

Expected behavior: no updates being performed and version number to have a value of 0.

Link to project that reproduces the issue (Docker required)
Notes on the project:

  • It contains both a “working-as-expected” and a “faulty” endpoint to test. The endpoints can be called using the Postman collection file placed in the root folder .test
  • When saved inside the ‘configuration’ JSON column, the serialized objects don’t contain the incriminated ‘id’ field (this is exclusion is done via Mapstruct), but removing this behavior doesn’t affect the result
  • This issue was originally encountered on a project that used these dependencies:
Hibernate 6.6.26

Spring Boot 3.4.9
Spring Cloud 2024.0.2
Spring Cloud GCP 6.3.1
  • The project uses a multi layer architecture like the original project, to preserve the original mapping choices made by Mapstruct that could potentially impact the issue

I suppose that the problem is due to deserialization producing Java types that are different from the ones you are encoding manually. Since JSON does not have a way to differentiate between the various size of integer and floating point types, it usually deserializes to the biggest type i.e. Long for integer values and Double for decimal values. If you encode an Integer in your Jackson JsonNode, it will be stored as IntNode, but during deserialization of the String, it will come back as LongNode.
Since entity dirty tracking depends on the equals method of the Java type you are using, it will mismatch, because Hibernate ORM must serialize and deserialize through the JSON format to copy the value, and even if the numerical values are equivalent, they are stored as objects of different type. So since the copy of the JsonNode at the point in time when you persist/merge and the JsonNode of your live object will not be equal according to their equals method, Hibernate ORM assumes that the field is dirty and execute an update query.

To solve this copy problem, it’s best if you use a custom Java type directly in your entity type instead of JsonNode or be careful to ensure you only ever store values that deserialize to the same type by default.
When you use a custom Java type, you can mark it as @Embeddable and Hibernate ORM will take care of dirty tracking through individual fields, but if you don’t want that, you can also just implement the equals method properly, though the copying will involve again JSON serialization and deserialization.

Also see a similar question I answered recently: Extra Update during Insert of Entity with JsonField@JdbcTypeCode(SqlTypes.JSON) property - #4 by beikov