skip to content

Why is declaring CascadeType.REMOVE (or CascadeType.ALL) on a JPA @ManyToMany association — or on the @ManyToOne side of a parent-child relationship — considered dangerous?

level: seniorimportance: should knowfreq 42%

answer

  1. cascade = ownership claim
  2. @ManyToMany REMOVE → deletes shared tags / FK violation
  3. join-table rows are removed automatically anyway
  4. REMOVE on @ManyToOne = parent + all siblings
  5. per-row DELETEs, not a bulk delete

basics

~20 s

Because the target is shared. Removing one post would delete tags other posts still use, or on a @ManyToOne would delete the parent and, through it, every sibling. Cascade must only follow edges where the source truly owns the target.

solid answer

~50 s

Cascade encodes ownership, and neither of those edges expresses ownership. On a `@ManyToMany` — `Post` ↔ `Tag` — deleting a post with `CascadeType.REMOVE` tells Hibernate to delete every tag that post used. Those tags are referenced by other posts, so you either wipe shared reference data or hit a foreign-key violation from the join table rows other posts still hold. You never need cascade there: the join-table rows are maintained automatically by the owning side's collection, so removing the post removes its links only. On a child's `@ManyToOne` — `Comment` → `Post` — cascade follows child to parent, so removing one comment removes the whole post, which then cascades back down and deletes every sibling comment. A single-record delete becomes an aggregate wipe. The rule: cascade REMOVE flows *down* an aggregate, from the root to entities that cannot exist without it, and only where nothing outside the aggregate references them.

code

java · 18 lines
java
// DANGEROUS: deleting a post deletes shared tags
@ManyToMany(cascade = CascadeType.ALL)
private Set<Tag> tags = new HashSet<>();

// SAFE: links are cleaned up by the mapping; tags survive
@ManyToMany
@JoinTable(name = "post_tag",
          joinColumns = @JoinColumn(name = "post_id"),
          inverseJoinColumns = @JoinColumn(name = "tag_id"))
private Set<Tag> tags = new HashSet<>();

// DANGEROUS: removing one comment removes the whole post
@ManyToOne(cascade = CascadeType.ALL)
private Post post;

// SAFE
@ManyToOne(fetch = FetchType.LAZY)
private Post post;

go deeper

for a junior

Say that cascade REMOVE deletes the target entities, and that on a many-to-many those targets are shared, so it destroys other rows' data.

for a middle

Add the direction argument for @ManyToOne and the fact that join-table rows are cleaned up by the mapping regardless of cascade.

for a senior

Bring in the operational cost (per-row DELETEs, forced initialisation) and the trade-off against database ON DELETE CASCADE with its stale-context consequence.

for a principal

Position cascade as the mapping-level expression of aggregate boundaries and describe the review rule you would enforce: no REMOVE across aggregate roots, references by id instead.

## Cascade is a claim about ownership `cascade = REMOVE` on an association is a statement: *if I go, everything on the other side of this edge goes with me.* That is true for composition (an order and its lines) and false for reference (an order and its customer, a post and its tags). Because the mapping shape alone cannot tell those apart, the damage from getting it wrong shows up only at runtime, in production, on a delete. ## Case 1 — @ManyToMany ```java @ManyToMany(cascade = CascadeType.ALL) // wrong @JoinTable(name = "post_tag", ...) private Set<Tag> tags = new HashSet<>(); ``` `em.remove(post)` now cascades remove onto every associated `Tag`. Two possible outcomes: - **FK violation** — other posts still have `post_tag` rows pointing at those tags, so the `delete from tag` fails on the join table constraint. Noisy, but at least it fails. - **Silent data loss** — if the schema is lax, or the other join rows happen to be gone, the tags are deleted. Every other post loses a tag it legitimately used. The crucial realisation is that you never needed cascade here. The join table is owned by the collection mapping: when the owning-side entity is removed, Hibernate deletes the `post_tag` rows for that post automatically. Removing the *links* is built in; removing the *targets* is what cascade adds, and it is exactly the wrong thing. The same logic rules out `orphanRemoval` on a many-to-many — the API does not offer it, for this reason. `CascadeType.PERSIST`/`MERGE` on a many-to-many is a subtler hazard: it is convenient for saving a post with brand-new tags, but it also lets a stale detached `Tag` copy overwrite the canonical row on merge, and it makes it easy to insert duplicate tag rows when the code did not look up the existing one first. ## Case 2 — cascade on the child's @ManyToOne ```java @ManyToOne(cascade = CascadeType.ALL) // wrong direction private Post post; ``` Cascade follows the direction of the association it is declared on. Here that is child → parent. `em.remove(comment)` cascades to `em.remove(post)`, and if `Post.comments` also cascades remove, that comes back down and deletes every other comment. Deleting one row destroys the aggregate. Reviewers should treat any `REMOVE`/`ALL` on a `@ManyToOne` as a defect until proven otherwise — the many side is by definition not the owner. ## Case 3 — the shared child inside a @OneToMany Even a genuine one-to-many can be unsafe if the child is referenced from elsewhere: an `Address` owned by `Customer` but also referenced by historical `Order` rows. Cascading remove from `Customer` deletes an address an order still points at. The test is not the cardinality of the mapping — it is whether *any* other aggregate holds a reference. ## Operational costs beyond correctness Even where cascade REMOVE is semantically right, know what it costs. Hibernate does not translate it into one bulk `DELETE`. It initialises the collection, loads every child as a managed entity, and emits a `DELETE` per row so lifecycle callbacks fire and cached entries are evicted. A parent with a large child collection turns a one-line `remove()` into thousands of statements and a large persistence context. For big deletions, prefer an explicit bulk JPQL delete of children followed by the parent, or database-level `ON DELETE CASCADE` — accepting in the latter case that the database deletes rows behind the ORM's back, so any entities already in the persistence context or second-level cache are stale until you clear them. ## Safe defaults - Start with no cascade; add `PERSIST, MERGE` where an aggregate is saved as a unit. - Add `REMOVE`/`orphanRemoval` only on `@OneToMany`/`@OneToOne` edges from an aggregate root to a child with no independent identity and no external references. - Never on `@ManyToMany`. Never `REMOVE` on `@ManyToOne`. - Across aggregate roots, prefer holding an id or a plain reference with no cascade at all.

  • If you must not cascade remove on a @ManyToMany, how do the join-table rows get cleaned up when a post is deleted?
    Automatically, by the collection mapping itself. The join table belongs to the owning side's association, so when Hibernate deletes the owning entity it first deletes that entity's rows from the join table. Cascade is not involved and is not needed — it would only extend the delete to the target entities, which is the harmful part.
  • When is database-level ON DELETE CASCADE preferable to CascadeType.REMOVE, and what do you give up?
    When the child collection is large and you care about the number of statements: the database removes the rows in one operation instead of Hibernate loading each child and issuing per-row DELETEs. You give up ORM awareness — no `@PreRemove` callbacks, no second-level-cache eviction, and any of those children already loaded into the persistence context or the cache are now stale, so you must clear the context after such a delete.

Cascade REMOVE on a many-to-many is like cancelling a magazine subscription and having the publisher shred the magazine for everyone else who subscribes to it.

saying these in an interview costs you the question

  • Adding CascadeType.ALL everywhere "so saving works"
  • Believing cascade is required for join-table rows to be cleaned up
  • Putting REMOVE or ALL on a @ManyToOne and calling it symmetric
  • Judging safety by the mapping's cardinality instead of by whether other aggregates reference the target
  • Assuming cascade REMOVE compiles to a single bulk DELETE

context