Hibernate Orphan Removal: orphanRemoval = true Explained

orphanRemoval = true deletes a child entity as soon as it leaves its parent’s association. See the SQL Hibernate 7 runs, how it differs from CascadeType.REMOVE, and the common mistakes.

Comparison grid: removing a step with CascadeType.REMOVE sets recipe_id to NULL and keeps the row, with orphanRemoval = true the row is deleted; deleting the recipe deletes all steps with both settings

Orphan removal in Hibernate deletes a child entity from the database when the child is removed from its parent. A child removed this way is called an orphan, because no parent points to it anymore and its row has no use. We turn the feature on with the orphanRemoval = true attribute of @OneToMany or @OneToOne, and Hibernate runs the DELETE for us when the transaction commits.

We use orphan removal for children that cannot exist without their parent, such as the steps of a recipe or the lines of an invoice. For example, when a user deletes a step in a recipe editor, our code removes the step from the list, and the row is gone after the commit.

The following example enables orphan removal on the steps of a recipe and removes steps inside a transaction, with the SQL of each line as a comment.

@OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Step> steps = new ArrayList<>();
recipe.getSteps().remove(heat);       // delete from Step where id=?
recipe.getSteps().clear();            // one delete per step
recipe.setSteps(new ArrayList<>());   // error: never replace the collection

Notice that the last line fails at commit, because Hibernate tracks the collection object it loaded, so we change that collection and never replace it.

Next, we compare orphan removal with CascadeType.REMOVE and run both cases on a recipe app. The FAQs at the end cover replaced collections, @ManyToMany, Spring Data JPA and the timing of the DELETE.

1. What Is Orphan Removal?

A foreign key in the child table links a parent and its children, so removing a child from the parent’s collection only breaks that link. What happens to the child row after that depends on the mapping.

  • Without orphanRemoval, Hibernate sets the foreign key to NULL and the row stays in the table.
  • With orphanRemoval = true, Hibernate deletes the row.

The CascadeType.REMOVE setting looks similar but reacts to a different action. It deletes the children only when the parent itself is deleted with em.remove().

Comparison grid: removing a step with CascadeType.REMOVE sets recipe_id to NULL and keeps the row, with orphanRemoval = true the row is deleted; deleting the recipe deletes all steps with both settings
Both settings delete the steps when the recipe is deleted. Only orphanRemoval deletes a step that is removed from the recipe.
Action in codeCascadeType.REMOVEorphanRemoval = true
em.remove(recipe)Steps deletedSteps deleted
recipe.removeStep(heat)Row kept, recipe_id = NULLRow deleted
recipe.getSteps().clear()No SQL, rows unchangedAll rows deleted
recipe.setNutrition(null) (one-to-one)Row keptRow deleted

With orphanRemoval = true, deleting the parent also deletes the children, even without CascadeType.REMOVE. So the choice depends on one question, namely whether a child can exist without its parent.

Decision tree: if the child is shared, as in many-to-many, use neither; if it cannot exist without the parent, use orphanRemoval = true with CascadeType.ALL; if it can exist alone but must be deleted with the parent, use CascadeType.REMOVE; otherwise use no cascade, as for a chef's recipes
Pick the setting by asking whether the child can exist without its parent.

2. Orphan Removal Example

The following example is a small recipe app built with Hibernate 7.4, Java 25 and H2. It runs both cases, a child with orphan removal and a child without it.

2.1. Domain Model

A recipe has steps (“Whisk”, “Heat”, “Fry”) and nutrition facts, which both belong to one recipe, so both use orphan removal. A chef has recipes, but a recipe can exist without its chef, so the chef-recipe association has no orphan removal. Tags such as “breakfast” are shared by many recipes.

Entity diagram: Recipe has many Steps and one Nutrition with orphanRemoval = true, Chef has many Recipes without orphanRemoval, and Recipe has a many-to-many association with Tag through the recipe_tag join table
Green associations use orphanRemoval. The chef’s recipes and the shared tags do not.

The parent declares orphanRemoval on the association, and the helper methods keep both sides in sync.

@OneToMany(mappedBy = "recipe", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Step> steps = new ArrayList<>();

@OneToOne(cascade = CascadeType.ALL, orphanRemoval = true)
@JoinColumn(name = "nutrition_id")
private Nutrition nutrition;

public void addStep(Step step) {
  steps.add(step);
  step.setRecipe(this);
}

public void removeStep(Step step) {
  steps.remove(step);
  step.setRecipe(null);
}
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "recipe_id")
private Recipe recipe;
@OneToMany(mappedBy = "chef")
private List<Recipe> recipes = new ArrayList<>();

public void removeRecipe(Recipe recipe) {
  recipes.remove(recipe);
  recipe.setChef(null);
}

2.2. Removing a Child Entity

We remove the “Heat” step from the pancake recipe, and the pancake recipe from its chef. Both calls look the same in Java, but each one runs different SQL.

emf.runInTransaction(em -> {
  Recipe pancakes = em.find(Recipe.class, recipeId);
  pancakes.removeStep(pancakes.findStep("Heat"));
});                                       // delete from Step where id=?

emf.runInTransaction(em -> {
  Chef chef = em.find(Chef.class, chefId);
  chef.removeRecipe(em.find(Recipe.class, recipeId));
});                                       // update Recipe set chef_id=?,name=?,nutrition_id=? where id=?

After the two transactions, the step row is gone, whereas the recipe row stays with chef_id = NULL.

Two cases: recipe.removeStep(heat) with orphanRemoval = true deletes the Heat row from the step table; chef.removeRecipe(pancakes) without orphanRemoval keeps the Pancakes row with chef_id NULL
The step is deleted. The recipe stays with chef_id = NULL, which is right, because a recipe can exist without its chef.

2.3. Orphan Removal in a One-to-One Association

On @OneToOne, the child becomes an orphan when the reference is set to null or replaced. For example, when a user recalculates the nutrition facts of a recipe, we set a new Nutrition object, and Hibernate deletes the old row.

recipe.setNutrition(null);                // update Recipe set nutrition_id=? ...
                                          // delete from Nutrition where id=?

recipe.setNutrition(new Nutrition(420));  // insert into Nutrition ...
                                          // update Recipe ...
                                          // delete from Nutrition where id=? (old row)

2.4. Deleting the Parent Entity

The call em.remove(recipe) deletes everything the recipe owns, while the shared tag stays.

delete from recipe_tag where recipe_id=?
delete from Step where id=?
delete from Step where id=?
delete from Recipe where id=?

The complete project on GitHub prints the SQL for each removal case and checks the results with 14 JUnit tests (mvn -q compile exec:java, mvn test).

3. Orphan Removal FAQs

3.1. Why Does Replacing the Collection Throw an Error?

The error comes from assigning a new list to the collection field. Hibernate finds orphans by comparing the collection object it loaded with a snapshot, and a new list is not that object, so Hibernate stops at commit.

How Hibernate finds orphans: it compares the loaded snapshot Whisk, Heat, Fry with the current list Whisk, Fry and deletes Heat; after recipe.setSteps(new ArrayList) the tracked collection is no longer referenced, so the commit fails
Hibernate finds orphans in the collection it tracks. A replaced collection cannot be tracked.
// Wrong: replaces the managed collection
recipe.setSteps(newSteps);

// Right: changes the managed collection
recipe.getSteps().clear();
newSteps.forEach(recipe::addStep);
jakarta.persistence.RollbackException: Error while committing the transaction [A collection with orphan deletion was no longer referenced by the owning entity instance: com.howtodoinjava.hibernate.orphan.Recipe.steps]

This error often comes from update code that copies a list from a DTO onto the entity. For example, a recipe edit form sends the new steps in a DTO, and the update code assigns that DTO list to the entity. So we keep the collection without a public setter.

3.2. Does orphanRemoval Work With @ManyToMany?

No. @ManyToMany has no orphanRemoval attribute, because the child is shared. Removing a tag from a recipe deletes only the link row.

pancakes.removeTag(breakfast);    // delete from recipe_tag where recipe_id=?
                                  // the Tag row stays

Never put CascadeType.REMOVE or CascadeType.ALL on @ManyToMany, because deleting one recipe would delete tags that other recipes still use.

3.3. Does orphanRemoval Work With Spring Data JPA?

Yes. The method save() calls merge() for an existing entity, and merge() applies the removed child to the managed copy, so the step is deleted. The steps must be loaded before the first transaction closes (for example with join fetch), otherwise reading them throws a LazyInitializationException.

detached.removeStep(detached.findStep("Heat"));
emf.runInTransaction(em -> em.merge(detached));   // delete from Step where id=?

3.4. When Does Hibernate Delete the Orphan?

Hibernate deletes an orphan at flush time, not when our code removes it from the collection. A few more rules decide whether the DELETE runs at all.

FactWhat it means
The DELETE runs at flushAt commit, or earlier when Hibernate flushes before a query, not at the remove() call
Works only on managed entitiesLoad the parent and change it in one transaction
clear() keeps the back referencestep.recipe still points to the recipe in memory; use removeStep() in a loop if the objects are used later
Moving a child to another parentHibernate 7.4 runs an UPDATE, but the JPA specification says portable code must not do it
Bulk JPQL delete from RecipeSkips orphan removal and cascades, and fails on the foreign key

4. Conclusion

The setting orphanRemoval = true deletes a child as soon as it leaves its parent, whereas CascadeType.REMOVE deletes children only when the parent is deleted.

We use orphan removal for children that cannot exist without their parent. We change the collection inside a transaction and never replace the collection object.

5. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.