orphanRemoval=true triggers loading of never-touched lazy @OneToMany collections on save() of a detached entity (Hibernate 5 → 6.6.53 regression?)

Hi everyone,

I’m running into unexpected behavior after migrating from Hibernate 5 to Hibernate 6.6.53.Final as part of a Spring Boot 2.7 → 3.x migration.

Setup

I have a parent entity with several @OneToMany collections mapped like this:

@OneToMany(mappedBy = "parent", cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY)
private List<Child> children = new ArrayList<>();

There are multiple such lazy collections on the same entity.

Steps to reproduce

  1. Load the parent entity in one transaction (the lazy collections are not accessed/initialized at this point).
  2. The entity becomes detached (e.g. transaction/session ends).
  3. In a later transaction, save()/merge() the detached parent entity — without ever touching any of the collections, and without having changed anything in them.

Expected behavior (and actual behavior under Hibernate 5)

Since the collections were never initialized and therefore cannot have been modified, I would expect Hibernate to skip them entirely during flush — no additional SELECTs. This is exactly what happened under Hibernate 5.

Actual behavior under Hibernate 6.6.53.Final

When saving the parent, Hibernate now issues SELECT queries to load every one of these lazy collections, even though they were never accessed. This happens for all of them, not just one, which multiplies the number of unnecessary queries significantly on entities with several such mappings.

Questions

  1. Is this a known/intended behavior change between Hibernate 5 and 6, related to how orphanRemoval snapshot comparison now works during flush?
  2. Is this considered a bug, or is there a design reason why an uninitialized collection with orphanRemoval=true must now be loaded on every save?
  3. Is there a recommended way to avoid this — short of dropping orphanRemoval and handling child deletion explicitly via a separate repository call?

I’m happy to put together a minimal reproducer using the test case template if that would help pin this down. Thanks in advance for any insight!

I wouldn’t say that it is intentional to run N queries for every collection. There should be a single join fetch instead, but we don’t yet detect that this kind of “bag” actually has unique elements, which is why we have to use separate select statements for now.
Does the behavior change if you remove orphanRemoval?
We do have an intentional behavior change regarding how cascades are processed, but that shouldn’t really make a difference in your case.

Please try to create a reproducer with our test case template and if you are able to reproduce the issue, create a bug ticket in our issue tracker and attach that reproducer.

Just for clarification it will not run N queries for each child collection, but only one for each collection.
So if i have for example 5 @OneToMany in the parent it will execute 1+5 queries during save of the parent.
But the collection are lazy and not loaded.
Hibernate 5 uses only one query here during merge.

And no, it does not change when i remove the orphanRemoval.
Only thing that canged this behavious is when i remove the CascadeType.Merge

Sounds like you are affected by HHH-20394, which should be solved once you update to the latest version.

I dont think that this describes my issue and i am already using Hibernate 6.6.53.

class RootEntity {
  @OneToMany(mappedBy="root", fetch=LAZY, cascade=ALL)
  Set<Child> children;
}

class Child{
  @ManyToOne(fetch=LAZY)
  RootEntity root;
}

In my case it looks like this.
Now i am loading the Root entity without fetching the children. So the set is not initialized.
When i now call merge on the root entity, it will load all the children. That means it will cascade the merge operation to the child collection even the set is not initialized.

AFAIU, this is exactly what HHH-20394 is about, but if you think you found another problem, please try to create a reproducer with our test case template and if you are able to reproduce the issue, create a bug ticket in our issue tracker and attach that reproducer.