This code looks like it deletes a parent and its children:
@Transactional
public void deleteOrder(Long orderId) {
Order order = em.find(Order.class, orderId);
em.remove(order);
}
If Order has a @OneToMany List<OrderItem> with cascade = CascadeType.REMOVE, this will issue individual DELETEs for each item before deleting the order — which is correct but slow at scale. If the collection uses cascade = ALL but not orphanRemoval, removing items from the list before calling em.remove(order) will leave orphan rows. If there is no cascade at all, it throws a FK constraint violation. Three different results from three minor annotation differences, all on code that looks identical in the service layer.
Here are the six most common delete bugs, each with the code that produces it and what actually fires in SQL.
Bug 1: cascade = REMOVE vs orphanRemoval = true — When Each Actually Fires
These two look interchangeable. They are not.
// cascade = REMOVE: deletes children when parent is removed
@OneToMany(mappedBy = "invoice", cascade = CascadeType.REMOVE)
private List<InvoiceLine> lines;
// What does NOT fire: removing a line from the collection
invoice.getLines().remove(line); // line stays in DB — only unlinked in memory
// orphanRemoval = true: deletes children when removed from collection
@OneToMany(mappedBy = "invoice", orphanRemoval = true)
private List<InvoiceLine> lines;
// Now this fires a DELETE:
invoice.getLines().remove(line); // DELETE FROM invoice_lines WHERE id = ? at flush
The rule: if the child cannot exist without the parent — invoice lines, order items, address on a profile — use orphanRemoval = true. If you just want the delete to propagate when the parent is removed, cascade = REMOVE is enough. Many production codebases have the wrong one and don’t notice until a UI allows item-level removal.
Bug 2: JPQL Bulk DELETE Bypasses Cascade and Callbacks
<pre class="wp-block-syntaxhighlighter-code">// This bypasses cascade, @PreRemove callbacks, and L2 cache invalidation
em.createQuery("DELETE FROM Order o WHERE o.createdAt < :cutoff")
.setParameter("cutoff", cutoffDate)
.executeUpdate();</pre>
If Order has cascade = REMOVE or FK constraints on OrderItem, this throws a FK violation or silently leaves orphan rows depending on the DB configuration. No @PreRemove callback fires. The L2 cache is not invalidated. The fix for bulk deletes with FK constraints: delete children first, then parents.
<pre class="wp-block-syntaxhighlighter-code">// Correct order for bulk delete with FK constraints
em.createQuery("DELETE FROM OrderItem i WHERE i.order.createdAt < :cutoff")
.setParameter("cutoff", cutoffDate).executeUpdate();
em.createQuery("DELETE FROM Order o WHERE o.createdAt < :cutoff")
.setParameter("cutoff", cutoffDate).executeUpdate();
// Also evict L2 cache regions if enabled
sessionFactory.getCache().evictEntityData(Order.class);
sessionFactory.getCache().evictEntityData(OrderItem.class);</pre>
Bug 3: Deleting from a @OneToMany Collection Causes SELECT-then-N-DELETEs
When you remove all items from a List with orphanRemoval = true, Hibernate issues one SELECT to load the collection, then one DELETE per item.
// For an invoice with 500 line items, this issues 501 SQL statements
invoice.getLines().clear(); // SELECT lines, then DELETE line WHERE id=? x500
For collections larger than ~50 items, use a targeted JPQL delete on the children before clearing the collection — or before removing the parent.
Bug 4: FK Constraint Violations from Wrong Delete Order
Hibernate’s ActionQueue orders operations during a flush, but it does not always guess the correct delete order when associations are complex or when you are mixing multiple aggregate roots in one transaction.
// Both removed in same transaction; FK on Project.ownerId -> User.id
em.remove(user); // scheduled first
em.remove(project); // scheduled second
// Flush: DELETE FROM users fires before DELETE FROM projects
// FK violation: projects still reference users
Fix: either reverse the remove order to respect FK direction, or delete the owning side first in a separate flush before the referenced side.
Bug 5: Soft Delete via @SQLDelete and the findAll Problem
@Entity
@SQLDelete(sql = "UPDATE products SET deleted = true WHERE id = ?")
// Missing @Where — all queries still return deleted records
public class Product {
private boolean deleted = false;
}
@SQLDelete redirects the DELETE SQL but does not add a WHERE clause to SELECT queries. Without @Where(clause = "deleted = false"), every findAll(), every JPQL query, every association traversal returns soft-deleted records. Hibernate 7’s @SoftDelete annotation handles both sides automatically; if you are on a legacy codebase using manual @SQLDelete, the @Where annotation is mandatory alongside it.
Bug 6: Deleting a Managed Entity in a Long-Lived Session — the Dirty-Check Resurrection
This one is rare but completely baffling when it happens. You call em.remove(entity) inside a long-running transaction. Before the flush, some other code path calls a setter on the same entity reference. Hibernate sees the mutation, removes the “scheduled for deletion” flag, and issues an UPDATE instead of a DELETE.
em.remove(product); // scheduled for DELETE
product.setStatus("ARCHIVED"); // mutation after remove
// Hibernate: UPDATE products SET status=? WHERE id=?
// DELETE never fires — entity was "resurrected" by the mutation
After calling em.remove(), treat the entity reference as invalid. Do not touch it. Do not pass it to other methods. Nullify it if needed.
Under the Hood: What ActionQueue Does with Delete Ordering
Hibernate’s ActionQueue collects all pending INSERT, UPDATE, and DELETE operations during a flush and reorders them to satisfy FK constraints. It does this by inspecting the entity mappings: if A has a FK to B, Hibernate knows B must be deleted after all As that reference it are gone.
The reordering works correctly for simple single-aggregate deletions. It can produce incorrect order when multiple aggregates with cross-references are deleted in the same flush, when cascade is not configured and you are deleting both sides manually, or when bidirectional associations are not properly synchronised (both sides must be updated for the ActionQueue to calculate the correct order). When ActionQueue gets the order wrong the fix is always the same: split the deletes across explicit em.flush() calls to force ordering.
Note on Compatibility: While Session.remove() aligns with the JPA EntityManager.remove() contract, features like MutationQuery, @SoftDelete, and StatelessSession are Hibernate-specific and not part of the standard Jakarta Persistence (JPA) specification.
In this guide, we’ll dive deep into the best practices for deleting entities, explore the new features in Hibernate 7, and help you choose the right strategy for your specific use case.
The Mental Model: The Deletion Lifecycle
Before looking at code, it is vital to understand how Hibernate views an entity during the deletion process. Unlike a direct SQL command, Hibernate manages the state of the object in memory first.
The State Transition:
Transient → Persistent (Managed) → Removed → (Flush/Commit) → Deleted (Database)
- Persistent: The object is currently managed by the Session and mapped to a row in the DB.
- Removed: After calling
session.remove(), the object is still in memory but scheduled for deletion.
- Flush: Hibernate synchronizes the state with the database, issuing the actual
DELETE SQL.
Continue reading Deleting Entities in Hibernate 7: Why Your Cascade Is Wrong, and Five Other Delete Bugs →