False positive dirty checking with JSON fields when using JsonInclude.Include.NON_NULL causes incorrect version increment on initial save

Problem Description

We’re experiencing an issue where Hibernate’s dirty checking for JSON-typed fields (@JdbcTypeCode(SqlTypes.JSON)) incorrectly detects changes during the initial save() operation, causing the @Version field to increment from 0 to 1 immediately after INSERT, even though no actual changes were made.

Environment

  • Hibernate Version: 6.x (via Spring Boot 3.5.8)

  • Database: MySQL 8.0

  • Jackson Version: 2.x (via Spring Boot)

  • JSON Mapping: Using JacksonJsonFormatMapper with custom ObjectMapper

Configuration

We’ve configured a custom ObjectMapper for Hibernate’s JSON handling to exclude null fields during serialization (to reduce database storage size):

@Bean
public HibernatePropertiesCustomizer jsonFormatMapperCustomizer(ObjectMapper objectMapper) {
    ObjectMapper hibernateObjectMapper = objectMapper.copy()
        .setSerializationInclusion(JsonInclude.Include.NON_NULL)
        .configure(SerializationFeature.ORDER_MAP_ENTRIES_BY_KEYS, true);
    
    return properties -> properties.put(
        MappingSettings.JSON_FORMAT_MAPPER, 
        new JacksonJsonFormatMapper(hibernateObjectMapper)
    );
}

Entity Structure

@Entity
@Table(name = "example_entity")
public class ExampleEntity {
    @Id
    @Column(name = "id")
    private UUID id;
    
    @Version
    @Column(name = "version")
    private Long version;
    
    @Column(name = "json_data")
    @JdbcTypeCode(SqlTypes.JSON)
    private Map<String, CustomObject> jsonData;  // Contains nested objects with null fields
    
    @Column(name = "metadata")
    @JdbcTypeCode(SqlTypes.JSON)
    private Map<String, Object> metadata;  // Another JSON field
    
    // ... other JSON-typed fields
}

Observed Behavior

  1. Expected: After save(), version should be 0

  2. Actual: After save(), version is 1

Sequence of Events (from debug logs)

  1. :white_check_mark: PRE_INSERT: Version is 0 (correct)

  2. :white_check_mark: POST_INSERT: Version is 0 (INSERT successful)

  3. :cross_mark: PRE_UPDATE: Hibernate detects change in jsonData field

  4. :cross_mark: POST_UPDATE: Version increments to 1 (incorrect)

The PRE_UPDATE event shows:

Changed properties: [jsonData, version]
Old version: 0, New version: 0

Root Cause Analysis

We believe the issue occurs because:

  1. During INSERT: Hibernate serializes the entity’s JSON fields using ObjectMapper with NON_NULL, excluding null fields from the JSON.

  2. After INSERT: Hibernate may reload the entity from the database to get generated values (like date_created, date_modified). During this reload:

    • Hibernate reads the JSON from the database (which doesn’t contain null fields)

    • Deserializes it using the same ObjectMapper with NON_NULL

    • Creates a new Java object from the deserialized JSON

  3. Dirty Checking: Hibernate compares the original state (snapshot taken before INSERT) with the current state (after reload). Even though both serialize to identical JSON bytes, the object graph structure may differ:

    • Original object: Has fields explicitly set to null (e.g., def.setAnotherField(null))

    • Deserialized object: Has fields that were never set (they don’t exist in the JSON, so Jackson never called the setter)

  4. False Positive: Hibernate’s dirty checking detects this structural difference and incorrectly triggers an UPDATE, incrementing the version.

Why NON_NULL Affects Both Serialization and Deserialization

According to this GitHub issue, JsonInclude.Include.NON_NULL can affect deserialization behavior. The JacksonJsonFormatMapper uses the same ObjectMapper instance for both serialization and deserialization, so we cannot separate the behavior.

What We’ve Tried

  1. Added ORDER_MAP_ENTRIES_BY_KEYS: To ensure consistent JSON key ordering (didn’t solve the issue)

  2. Event Listeners: Attempted to detect and prevent false positive updates in PreUpdateEventListener, but returning true doesn’t seem to veto the update in our Hibernate version

  3. Documentation Research: Found similar issues but no clear solution for separating serialization/deserialization behavior with JacksonJsonFormatMapper

Questions

  1. Is there a way to configure JacksonJsonFormatMapper to use different ObjectMapper instances for serialization and deserialization?

    • We want NON_NULL for serialization (to save space) but standard behavior for deserialization (to avoid structural differences)
  2. Is there a Hibernate configuration to prevent automatic entity reload after INSERT?

    • This might prevent the false positive detection
  3. Is there a better way to handle JSON field dirty checking that accounts for NON_NULL serialization?

    • Perhaps a custom FormatMapper implementation?
  4. Has anyone else encountered this issue and found a solution?

  5. Is this a known limitation when using NON_NULL with Hibernate’s JSON type support?

Additional Context

  • The issue affects multiple JSON-typed fields, not just jsonData

  • We’re using @Version for optimistic locking, so accurate version tracking is important

Minimal Reproducible Example

// Entity
@Entity
public class TestEntity {
    @Id
    private UUID id;
    
    @Version
    private Long version;
    
    @JdbcTypeCode(SqlTypes.JSON)
    private Map<String, Object> jsonField;  // Contains nested objects with null fields
}

// Test
@Test
void testVersionAfterSave() {
    TestEntity entity = new TestEntity();
    entity.setJsonField(createJsonWithNulls());  // Some nested fields are null
    
    entity = repository.save(entity);
    
    assertThat(entity.getVersion()).isEqualTo(0L);  // ❌ Fails: actual is 1L
}

This has been asked a couple of times already. See e.g. Extra Update during Insert of Entity with JsonField@JdbcTypeCode(SqlTypes.JSON) property

Please compare the output of the FormatMapper.fromString( FormatMapper.toString() ) to see if the objects equal, since dirty checking is based on that.

I appreciate that you’re trying to give me a lot of context so I don’t have to ask many follow up questions, but this AI generated write-ups are tiring to read as they are very long. Sorry for not going into every detail that you present here, but the bottom line is, Hibernate ORM is not detecting false positives, but rather you are causing this problem with your configuration.
I don’t understand why that listener would be necessary. My guess is that you’re simply missing a proper equals implementation in one of your classes.

You could try to set a breakpoint in org.hibernate.persister.entity.DirtyHelper#findDirty to see which field causes Hibernate ORM to think that your entity is dirty and then try to understand further what you may be missing.