A startup ran cascade = CascadeType.ALL on a @ManyToOne User from their Project entity. The reasoning was sensible: they wanted deleting a project to clean everything up automatically. It worked perfectly in testing, where each project belonged to a unique test user.
In production, users had multiple projects. When a user deleted their first project, Hibernate cascaded REMOVE to the User entity. The user record was deleted. The user’s other projects — with all their data — were gone by FK cascade. The account was gone. Three months of work, in two SQL statements. Support ticket volume spiked. The rollback from backup took four hours.
The fix was one annotation change: replace cascade = ALL with cascade = {CascadeType.PERSIST, CascadeType.MERGE}. But understanding why that specific pair — and not ALL, not REMOVE, not nothing — requires a proper decision tree.
1. The Seven Cascade Types
| Type | What it propagates | When it fires |
|---|---|---|
PERSIST | em.persist(parent) | When you call persist on the parent |
MERGE | em.merge(parent) | When you call merge on the parent |
REMOVE | em.remove(parent) | When you call remove on the parent |
REFRESH | em.refresh(parent) | When you reload parent state from DB |
DETACH | em.detach(parent) | When parent is removed from persistence context |
ALL | All of the above | Every lifecycle operation on the parent |
Hibernate: LOCK | session.lock(parent) | When parent is re-locked; re-attaches static child |
2. The Decision Tree by Relationship
@OneToMany: composition vs aggregation
The starting question for any @OneToMany is: can the child exist without the parent?
Composition (child cannot exist without parent): An invoice has line items. A line item without an invoice is meaningless and should not exist. Use cascade = CascadeType.ALL with orphanRemoval = true. Deleting the invoice cascades the remove to all line items. Removing a line item from the collection marks it as an orphan and deletes it. The child’s lifecycle is completely owned by the parent.
@Entity
public class Invoice {
@OneToMany(mappedBy = "invoice",
cascade = CascadeType.ALL,
orphanRemoval = true)
private List<InvoiceLine> lines = new ArrayList<>();
}
Aggregation (child can exist independently): A course has enrolled students. A student without that course still has an account and other enrollments. Use cascade = {CascadeType.PERSIST, CascadeType.MERGE}. Saving a course with new students should propagate. Deleting a course should not delete the students — it should only delete the association.
@Entity
public class Course {
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(name = "course_students", ...)
private Set<Student> students = new HashSet<>();
}
@ManyToOne: almost never cascade
This is where the startup bug happened. A @ManyToOne association points from a child to a shared parent. The parent is typically shared by many children. Cascading REMOVE from child to parent means: “when I delete this order, delete the customer who placed it”. That is almost never what you want.
The correct answer for most @ManyToOne relationships is: no cascade, or at most {PERSIST, MERGE} if you are creating the parent entity alongside its first child. Cascade REMOVE on @ManyToOne is almost always a bug.
@Entity
public class Order {
// WRONG: cascade = ALL here means deleting an order deletes the customer
@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
private Customer customer;
// RIGHT: no cascade, or {PERSIST, MERGE} if customer is always new with order
@ManyToOne(fetch = FetchType.LAZY)
private Customer customer;
}
@OneToOne: depends on ownership
A @OneToOne where the child is truly owned by the parent (a user profile that only makes sense attached to a user) warrants cascade = ALL with orphanRemoval = true. A @OneToOne that references a shared entity (a billing address reused by multiple accounts) warrants no cascade at all.
@ManyToMany: never cascade REMOVE
The join table contains the relationship; the referenced entities are independent. cascade = ALL on a @ManyToMany means deleting any entity that participates in the relationship will cascade removes across the join table and then to every other entity on the other side. For a User-Role relationship: deleting one user deletes every role, breaking every other user’s permissions.
For @ManyToMany, use cascade = {PERSIST, MERGE} at most. Never REMOVE. (For the full @ManyToMany guidance including when to convert it to two @OneToMany, see @ManyToMany Is Almost Always Wrong.)
3. orphanRemoval vs cascade = REMOVE
These two look similar but fire at different triggers. The distinction matters when you are removing children selectively without deleting the parent.
| Behaviour | cascade = REMOVE | orphanRemoval = true |
|---|---|---|
| Parent deleted via em.remove() | Yes, children are deleted | Yes, children are deleted |
| Child removed from collection: parent.getItems().remove(item) | No — item stays in DB; only the collection changes in memory | Yes — item is scheduled for DELETE at next flush |
| Parent not deleted; child just unlinked | No delete | DELETE fires on the orphaned child |
@Transactional
public void removeFirstLine(Long invoiceId) {
Invoice invoice = em.find(Invoice.class, invoiceId);
InvoiceLine firstLine = invoice.getLines().get(0);
invoice.getLines().remove(firstLine);
// With orphanRemoval = true: DELETE FROM invoice_lines WHERE id = ? fires at flush
// With cascade = REMOVE only: no DELETE; the row stays in the DB
// The invoices record is NOT deleted in either case
}
When you need selective child deletion without removing the parent, you need orphanRemoval = true. Using only cascade = REMOVE and expecting collection.remove(child) to delete the row from the database is a very common source of orphan data leaks.
4. Under the Hood: How Hibernate Walks the Cascade Graph
When you call em.persist(parent), Hibernate does not just write the parent entity. If the mapping has cascade = PERSIST, Hibernate calls an internal method — CascadeEntityPersister.cascade() effectively — that recursively processes each association field on the entity, checking whether it holds a transient or detached value that should be cascaded.
For collections, Hibernate iterates every element. For a single-valued association, it processes that element. For REMOVE, the cascade happens at the time you call em.remove(parent), and Hibernate walks down to children that were already loaded in the persistence context. Children that were not loaded are not cascaded to — they rely on either database-level FK constraints or on Hibernate loading them lazily during the remove walk.
This is why removing a parent with a large collection under cascade = ALL can be catastrophically slow: Hibernate must load all children into the persistence context before it can cascade the remove. An Invoice with 10,000 line items loaded into the identity map, 10,000 field snapshots taken, 10,000 individual DELETEs issued. For collections beyond a few hundred items, use a bulk JPQL DELETE instead of cascading:
@Transactional
public void deleteInvoice(Long invoiceId) {
// Step 1: bulk delete children without loading them
em.createQuery("DELETE FROM InvoiceLine l WHERE l.invoice.id = :id")
.setParameter("id", invoiceId)
.executeUpdate();
// Step 2: delete parent
Invoice invoice = em.getReference(Invoice.class, invoiceId);
em.remove(invoice);
}
Note: bulk JPQL DELETE bypasses cascade and lifecycle callbacks on the children. That is a trade-off, not a bug; for mass deletion of items that have no meaningful lifecycle hooks, it is the right trade-off. (For the full coverage of deletion pitfalls including this one, see Deleting Entities in Hibernate 7.)
5. Production Failure: cascade = ALL on @ManyToOne
Returning to the startup incident. The offending mapping was:
@Entity
public class Project {
@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "owner_id")
private User owner;
}
When a user called DELETE /projects/42, the service did:
Project project = em.find(Project.class, 42L);
em.remove(project);
// cascade = ALL fires REMOVE on project.owner
// User entity is removed from persistence context
// DELETE FROM users WHERE id = ? fires at flush
The user entity was deleted. All other projects owned by that user had a FK referencing the now-deleted user row. Depending on the DB schema, either the FK constraint failed loudly (best case), or they were silently orphaned or cascade-deleted at the database level (worst case — which is what happened).
The mental model that led here was reasonable: “I want cleaning up a project to clean everything related to it”. But @ManyToOne is not ownership. The owner is shared. The correct fix:
@Entity
public class Project {
// No cascade: the User lifecycle is independent of the Project lifecycle
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "owner_id")
private User owner;
}
Bad → Improved: cascade = ALL on @ManyToOne
Bad — cascade ALL on a shared parent:
@Entity
public class Comment {
@ManyToOne(fetch = FetchType.LAZY, cascade = CascadeType.ALL)
@JoinColumn(name = "author_id")
private User author;
// Deleting any comment deletes the author
// Author's other comments, posts, and profile are gone
}
Improved — no cascade on shared parent; children own their own lifecycle:
@Entity
public class Comment {
// No cascade: User outlives any individual comment
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "author_id")
private User author;
}
@Entity
public class User {
// Cascade ALL on owned children is correct:
// a user's comments die with the user account
@OneToMany(mappedBy = "author",
cascade = CascadeType.ALL,
orphanRemoval = true)
private List<Comment> comments = new ArrayList<>();
}
Cascade belongs on the side that owns the lifecycle, walking downward through the object graph. It should never bubble upward from a @ManyToOne child to its shared parent.
See Also
- 📘 @OneToMany Done Right — bidirectional sync, Set vs List, the MultipleBagFetchException trap
- 📘 @OneToOne in Hibernate 7 — five mapping strategies and when orphanRemoval applies
- 📘 @ManyToMany Is Almost Always Wrong — why @ManyToMany with cascade = REMOVE is almost always a data integrity bug
- 📘 Deleting Entities in Hibernate 7 — bulk DELETE vs cascade, ActionQueue ordering, soft deletes
- 📘 The Hibernate 7 Persistence Context — how cascade interacts with state transitions
Frequently Asked Questions
Should I always use cascade = ALL on @OneToMany?
Only when the relationship is a true composition — the child entity has no meaningful existence outside the parent. For aggregation (children that can stand alone or are shared), use {PERSIST, MERGE} at most and never cascade REMOVE.
What is the difference between cascade REMOVE and database ON DELETE CASCADE?
JPA cascade REMOVE happens at the application layer: Hibernate loads children into the persistence context and issues individual DELETE statements, respecting lifecycle callbacks and dirty checking. Database ON DELETE CASCADE happens at the DB engine level, is not visible to Hibernate, and can leave the persistence context in a stale state (Hibernate still thinks those entities exist). For large child collections, database-level cascade is faster but bypasses all Hibernate lifecycle hooks and cache invalidation.
Why does my cascade = ALL cause an OutOfMemoryError on delete?
Cascading REMOVE loads every child entity into the persistence context before deleting them. For a parent with thousands of children, that is thousands of managed objects plus their snapshots accumulating in heap. Use a bulk JPQL DELETE on the children before calling em.remove(parent). It is two statements instead of N+1, and the children never enter the persistence context.
Conclusion
Cascade type selection is a domain modelling decision that Hibernate enforces mechanically. The central question is always ownership: does this entity own the lifecycle of the associated entity, or does it merely reference a shared one? Ownership flows in one direction — downward from parent to child in a composition. Cascade ALL belongs on that downward path. cascade REMOVE on a @ManyToOne points upward at a shared entity, and it will eventually delete data you did not intend to delete. orphanRemoval fills the gap that cascade REMOVE leaves: it deletes children when they are unlinked from the parent collection, not just when the parent is deleted. Know the difference, draw your ownership boundaries clearly before writing the annotation, and the delete-wipes-the-account production incident stays a cautionary tale rather than a 4 AM war story.