Spring Boot
JPA Cascade Types: ALL, PERSIST, MERGE, REMOVE and orphanRemoval
Understand JPA cascade types in Spring Boot: what PERSIST, MERGE, REMOVE, REFRESH, DETACH and ALL do, how orphanRemoval differs, which cascade to use for owned and shared entities, and how to fix the errors wrong cascade settings cause.
What Is Cascading in JPA?
By default, JPA operations apply only to the entity you pass in. If you persist an `Order` that holds new `OrderItem` objects, the items are not saved unless you save them yourself. **Cascading** tells JPA to propagate an operation from one entity to the entities it references.
Three points clear up most confusion:
- **Cascade is a JPA feature, not a database feature.** It makes Hibernate issue the extra `persist`, `merge` or `remove` calls. A database `ON DELETE CASCADE` is a separate mechanism (compared below).
- **The default is no cascade.** You opt in per association.
- **Cascade applies only to operations that go through the `EntityManager`.** Bulk JPQL or native `DELETE` and `UPDATE` queries bypass it.
Examples use Spring Boot 3 imports (`jakarta.persistence.*`). On Spring Boot 2 the package is `javax.persistence`.
Cascade Types at a Glance
| Cascade type | EntityManager operation | Typically triggered by | Use for |
|---|---|---|---|
| `PERSIST` | `persist()` | `save()` of a **new** entity | Saving new children with the parent |
| `MERGE` | `merge()` | `save()` of an **existing or detached** entity | Updating a detached parent graph |
| `REMOVE` | `remove()` | `delete()` | Deleting owned children with the parent |
| `REFRESH` | `refresh()` | `entityManager.refresh()` (no Spring Data method) | Reloading parent and children from the database |
| `DETACH` | `detach()` | `entityManager.detach()` | Detaching a whole graph |
| `ALL` | All five above | | Parent fully owns the child |
**How `save()` chooses:** Spring Data's `save()` calls `persist()` when the entity is considered new (null id, or null `@Version`) and `merge()` otherwise. So the cascade type that applies depends on whether the parent is new.
Hibernate has extra, Hibernate-specific cascade options through `@Cascade`. You rarely need them, and the JPA types cover normal cases.
Where Cascade Is Declared (Direction and Owning Side)
Cascade flows **from the entity that declares it to the associated entity**. It works on any association annotation (`@OneToMany`, `@OneToOne`, `@ManyToOne`, `@ManyToMany`), and on either the owning side or the `mappedBy` (inverse) side. The `mappedBy` attribute only controls which side writes the foreign key. It has no effect on cascading.
Two practical rules:
- Declare cascade on the side that **owns the lifecycle**, normally the parent's `@OneToMany`.
- For a bidirectional association, keep both sides in sync with helper methods. Without them, the database only sees changes made to the owning side.
The table is `orders` because `order` is a reserved SQL word, so the entity is called `PurchaseOrder`.
Java
@Entity
@Table(name = "orders")
public class PurchaseOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String reference;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
protected PurchaseOrder() { }
public PurchaseOrder(String reference) { this.reference = reference; }
// Keep both sides of the association in sync
public void addItem(OrderItem item) {
items.add(item);
item.setOrder(this);
}
public void removeItem(OrderItem item) {
items.remove(item);
item.setOrder(null);
}
public List<OrderItem> getItems() { return items; }
}
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private int quantity;
private BigDecimal unitPrice;
@ManyToOne(fetch = FetchType.LAZY) // no cascade: an item must never delete its order
@JoinColumn(name = "order_id", nullable = false)
private PurchaseOrder order;
protected OrderItem() { }
public OrderItem(String name, int quantity, BigDecimal unitPrice) {
this.name = name;
this.quantity = quantity;
this.unitPrice = unitPrice;
}
void setOrder(PurchaseOrder order) { this.order = order; }
}CascadeType.PERSIST
`PERSIST` propagates `persist()` from the parent to its children. Saving a new parent that holds new (transient) children saves the children too, without a `save()` call for each one.
Without it, the new children are not saved. Depending on the mapping, Hibernate may also throw a `TransientPropertyValueException` ("object references an unsaved transient instance") at flush.
**Watch out for detached children.** `PERSIST` is meant for **new** objects. If a child already has an id and is detached (loaded in an earlier transaction), cascading persist to it fails with `PersistentObjectException: detached entity passed to persist`. Load the child inside the same transaction so it is managed, or use a `MERGE` flow instead.
`PERSIST` alone does not cascade merge. If you also update detached parents, you need `MERGE` as well (or `ALL`).
Java
PurchaseOrder order = new PurchaseOrder("REF-001");
order.addItem(new OrderItem("Widget", 2, new BigDecimal("9.99")));
order.addItem(new OrderItem("Gadget", 1, new BigDecimal("49.99")));
orderRepository.save(order); // new parent -> em.persist(order) -> persist cascades to both items
// INSERT INTO orders ...
// INSERT INTO order_items ... (one per item, at flush)
// Problem case: a detached child in a persist cascade
OrderItem detachedItem = itemRepository.findById(5L).orElseThrow(); // detached after its transaction ended
PurchaseOrder another = new PurchaseOrder("REF-002");
another.addItem(detachedItem);
orderRepository.save(another);
// PersistentObjectException: detached entity passed to persist: OrderItemCascadeType.MERGE
`MERGE` propagates `merge()` from a parent to its children. It matters when you pass a **detached** parent graph to `save()`: with `MERGE`, changes to its children are copied onto managed instances. Without it, the children's changes are silently ignored.
**You often don't need `merge` at all.** The simplest and safest update pattern is to load the entity inside a `@Transactional` method, change it, and let dirty checking write the changes at commit. No `save()`, no detached copies, no `MERGE` cascade needed.
Two merge gotchas:
- `save()` returns the **managed copy**. The instance you passed in stays detached, so always use the returned value.
- Spring Boot enables Open Session in View by default, so in a web request the entity may still be managed after the service method returns. Don't rely on that. Design the service method so it works either way.
Java
// Preferred: change managed entities inside a transaction. No merge needed.
@Transactional
public PostResponse updateComment(Long postId, Long commentId, UpdateCommentRequest req) {
Post post = postRepository.findByIdWithComments(postId).orElseThrow();
post.getComments().stream()
.filter(c -> c.getId().equals(commentId))
.findFirst().orElseThrow()
.setBody(req.body());
return PostResponse.from(post); // dirty checking issues the UPDATE at commit
}
// In the repository
@Query("select p from Post p left join fetch p.comments where p.id = :id")
Optional<Post> findByIdWithComments(@Param("id") Long id);
// Only when you really receive a detached graph (rare)
@OneToMany(mappedBy = "post", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private List<Comment> comments = new ArrayList<>();
Post managed = postRepository.save(detachedPost); // merge() -> use the RETURNED instance
// WITH MERGE: changed comments are merged -> UPDATE comments SET body = ? WHERE id = ?
// WITHOUT MERGE: comment changes are silently ignoredCascadeType.REMOVE
`REMOVE` propagates `remove()` from the parent to its children. Deleting the parent deletes the children. It is correct for **owned** children (`OrderItem` owned by `PurchaseOrder`) and dangerous for **shared** entities. Cascading REMOVE from `Department` to `Employee` deletes every employee when you delete a department.
How Hibernate does it matters for performance:
- It **loads the collection first**, then issues one `DELETE` per child by id, then deletes the parent. Children are deleted before the parent.
- For large collections that means many queries. Alternatives are a bulk `DELETE` query, or a database foreign key with `ON DELETE CASCADE` (see the comparison below).
Bulk JPQL deletes bypass cascading, `orphanRemoval`, lifecycle callbacks and `@Version`, and they do not update the persistence context, so use `clearAutomatically`.
Java
// Owned children: REMOVE cascade is correct
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
orderRepository.deleteById(1L);
// SELECT ... FROM orders WHERE id = 1
// SELECT ... FROM order_items WHERE order_id = 1 (collection loaded)
// DELETE FROM order_items WHERE id = ? (once per item)
// DELETE FROM orders WHERE id = 1
// Large collections: delete the children in bulk first, then the parent
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("DELETE FROM OrderItem i WHERE i.order.id = :orderId")
int deleteByOrderId(@Param("orderId") Long orderId);
@Transactional
public void deleteOrder(Long id) {
itemRepository.deleteByOrderId(id);
orderRepository.deleteById(id);
}
// DANGEROUS: cascade REMOVE onto independent entities
@Entity
public class Department {
@OneToMany(mappedBy = "department", cascade = CascadeType.ALL) // WRONG: employees are independent
private List<Employee> employees;
}
// departmentRepository.delete(department) now deletes every employee in it.
// CORRECT: no cascade. Reassign employees before deleting the department.
@OneToMany(mappedBy = "department")
private List<Employee> employees;CascadeType.REFRESH and CascadeType.DETACH
Both are uncommon, and neither is reachable through a Spring Data repository method, so you need an injected `EntityManager`.
- **`REFRESH`** reloads an entity from the database and discards unflushed in-memory changes. With the cascade, the children are reloaded too. The entity must be **managed**, otherwise `refresh` throws an `IllegalArgumentException`.
- **`DETACH`** stops the persistence context tracking an entity. With the cascade, its children are detached too. Unflushed changes are lost, so flush first if you need them. `entityManager.clear()` detaches everything without any cascade.
Don't add either cascade to every association "just in case". Add them only where you actually call these operations.
Java
@OneToMany(mappedBy = "report", cascade = {CascadeType.PERSIST, CascadeType.MERGE, CascadeType.REFRESH})
private List<ReportSection> sections = new ArrayList<>();
@Service
public class ReportService {
@PersistenceContext
private EntityManager em;
@Transactional
public Report reload(Long id) {
Report report = em.find(Report.class, id);
report.getSections().get(0).setTitle("Edited in memory"); // not flushed
em.refresh(report);
// WITH REFRESH cascade: report AND sections reloaded, the edit is discarded
// WITHOUT it: only the report is reloaded; sections keep their dirty state
return report;
}
}orphanRemoval vs CascadeType.REMOVE
These are often confused. They fire in different situations:
- **`CascadeType.REMOVE`** fires when the **parent is removed**. The children are deleted with it.
- **`orphanRemoval = true`** fires when a child is **removed from the parent's collection**. The child row is deleted even though the parent stays.
Most owned parent-child relationships want both, which is what `cascade = ALL, orphanRemoval = true` gives you.
| Mapping | Parent deleted | Child removed from the collection |
|---|---|---|
| `cascade = ALL`, no orphanRemoval | Children deleted | Child row **stays**. If your helper also sets `child.setOrder(null)`, its foreign key becomes NULL (fails if the column is NOT NULL). If you only change the list, nothing changes in the database. |
| `cascade = ALL` + `orphanRemoval = true` | Children deleted | Child **deleted** |
| `orphanRemoval = true` only | Children deleted (the JPA spec says `orphanRemoval` also cascades remove) | Child deleted |
| No cascade, no orphanRemoval | Foreign key violation if children exist | Child row stays |
I still recommend declaring `CascadeType.REMOVE` or `ALL` explicitly, so the intent is readable.
Java
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
// Parent removed: REMOVE cascade
orderRepository.deleteById(1L);
// Child unlinked: orphanRemoval, the parent stays
@Transactional
public void removeFirstItem(Long orderId) {
PurchaseOrder order = orderRepository.findByIdWithItems(orderId).orElseThrow();
order.removeItem(order.getItems().get(0));
// no save() needed: dirty checking issues DELETE FROM order_items WHERE id = ?
}orphanRemoval Gotchas
- **Never replace the collection instance.** `order.setItems(new ArrayList<>())` fails with `A collection with cascade="all-delete-orphan" was no longer referenced by the owning entity instance`. Modify the existing collection with `clear()` and `addAll()` instead.
- **Moving a child to another parent deletes it.** Removing a child from parent A's collection and adding it to parent B's can delete the row. Don't use `orphanRemoval` where children move between parents or can be shared.
- **It only exists on `@OneToMany` and `@OneToOne`.** It is not available on `@ManyToOne` or `@ManyToMany`.
- **Keep both sides in sync.** Without `removeItem` setting the child's parent to null, the in-memory state is inconsistent until it is reloaded.
Java
// WRONG: replaces the managed collection
order.setItems(newItems);
// CORRECT: mutate the existing collection
order.getItems().clear();
order.getItems().addAll(newItems);JPA Cascade vs Database ON DELETE CASCADE
Both delete children when a parent is deleted, but they work very differently.
| | JPA `CascadeType.REMOVE` | Database `ON DELETE CASCADE` |
|---|---|---|
| Where it runs | In the application (Hibernate) | In the database |
| Queries | Loads the children, then one `DELETE` each | One `DELETE` of the parent, the database removes children |
| Lifecycle callbacks and auditing | Run for each child | Skipped |
| Persistence context | Stays consistent | Already-loaded children become stale |
| Applies to bulk and native deletes | No | Yes, always |
Use JPA cascade when you need callbacks or auditing on children, or when the collections are small. For very large child collections, prefer a foreign key with `ON DELETE CASCADE` in your migration. Hibernate's `@OnDelete(action = OnDeleteAction.CASCADE)` on the association tells Hibernate the database will remove the children, so it skips its own per-row deletes. Check the Hibernate documentation for your version before relying on its exact behaviour.
sql
-- Flyway migration: the database does the cascading
ALTER TABLE order_items
ADD CONSTRAINT fk_order_items_order
FOREIGN KEY (order_id) REFERENCES orders (id) ON DELETE CASCADE;Which Cascade Should You Use?
Decide by asking: **who owns the child's lifecycle?**
| Relationship | Example | Recommended |
|---|---|---|
| Parent owns the child (composition) | Order to OrderItem, Post to Comment | `cascade = ALL` + `orphanRemoval = true` |
| Reference to an independent entity | Order to Customer, Product to Category | **No cascade** |
| Many-to-many between independent entities | Student to Tag | **No cascade** (usually) |
| Created together, deleted separately | User to Address that others can reuse | `PERSIST` + `MERGE` |
| Append-only records | Order to AuditLog | `PERSIST` only |
Cascade belongs on the `@OneToMany` or `@OneToOne` of the **owner**. On a `@ManyToOne` it is almost always a mistake, because it propagates from the child up to the parent.
Java
// Owned children
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
// Independent entity: no cascade
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
@ManyToMany
@JoinTable(name = "student_tags",
joinColumns = @JoinColumn(name = "student_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id"))
private Set<Tag> tags = new HashSet<>();
// Created with the parent, removed separately
@OneToOne(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinColumn(name = "billing_address_id")
private Address billingAddress;
// Append-only
@OneToMany(mappedBy = "order", cascade = CascadeType.PERSIST)
private List<AuditLog> auditLogs = new ArrayList<>();Testing Cascade Behaviour
Cascade bugs are easy to introduce and hard to spot by reading code, so test each behaviour you rely on. Flush and clear the persistence context between steps so you read back what really reached the database, not the cached entities.
`@DataJpaTest` uses an in-memory database by default. For constraints and cascading foreign keys, run it against your real database with Testcontainers.
Java
@DataJpaTest
class OrderCascadeTest {
@Autowired private TestEntityManager em;
@Autowired private OrderRepository orderRepository;
@Autowired private OrderItemRepository itemRepository;
@Test
void persistCascadesToNewItems() {
PurchaseOrder order = new PurchaseOrder("REF-001");
order.addItem(new OrderItem("Widget", 2, new BigDecimal("9.99")));
orderRepository.save(order);
em.flush();
em.clear();
assertThat(itemRepository.count()).isEqualTo(1);
}
@Test
void removingAnItemDeletesItButKeepsTheOrder() {
PurchaseOrder order = new PurchaseOrder("REF-002");
order.addItem(new OrderItem("Widget", 2, new BigDecimal("9.99")));
PurchaseOrder saved = orderRepository.saveAndFlush(order);
saved.removeItem(saved.getItems().get(0));
em.flush();
em.clear();
assertThat(itemRepository.count()).isZero();
assertThat(orderRepository.count()).isEqualTo(1);
}
@Test
void deletingTheOrderDeletesItsItems() {
PurchaseOrder order = new PurchaseOrder("REF-003");
order.addItem(new OrderItem("Widget", 2, new BigDecimal("9.99")));
Long id = orderRepository.saveAndFlush(order).getId();
em.clear();
orderRepository.deleteById(id);
em.flush();
assertThat(itemRepository.count()).isZero();
}
}Troubleshooting Cascade Errors
| Error or symptom | Usual cause | Fix |
|---|---|---|
| `detached entity passed to persist` | PERSIST cascaded to a child that already has an id and is detached | Load the child in the same transaction, use a merge flow, or remove PERSIST for that association |
| `TransientPropertyValueException: object references an unsaved transient instance` | A new child is referenced but never persisted (no PERSIST cascade, or the child was saved before its parent) | Add `PERSIST`, or save the referenced entity first |
| `A collection with cascade="all-delete-orphan" was no longer referenced` | Collection instance was replaced with `setItems(...)` | Use `clear()` and `addAll()` on the existing collection |
| Foreign key violation when deleting the parent | No REMOVE cascade, or other tables also reference the child | Delete dependents first, add REMOVE, or use `ON DELETE CASCADE` |
| Child changes not saved | No MERGE cascade, or you edited a detached copy | Change the managed entity inside `@Transactional`, or use the instance `save()` returns |
| Rows deleted unexpectedly | REMOVE or `orphanRemoval` on a shared association, or a child moved between parents | Remove the cascade; don't use `orphanRemoval` on shared children |
| Deleting a parent runs hundreds of queries | REMOVE cascade deletes row by row | Bulk delete the children, or use a database `ON DELETE CASCADE` |
| Extra `SELECT` before every insert | Assigned ids make Spring Data treat the entity as existing and call `merge` | Add a `@Version` field, or implement `Persistable` |
| `LazyInitializationException` during save or delete | Collection touched outside a transaction | Put the operation in a `@Transactional` method |
| `StackOverflowError` on bidirectional entities | Lombok `@Data`, `toString` or `hashCode` walks both sides | Exclude the association from `toString` and `equals`/`hashCode` |
Common Cascade Mistakes
- **`CascadeType.ALL` on a `@ManyToOne`** to a shared entity. Deleting the order deletes the customer.
- **`CascadeType.REMOVE` on a child's `@ManyToOne`.** Deleting one item deletes its order, and through the order's cascade, every other item.
- **`CascadeType.ALL` on a `@ManyToMany`.** Deleting a student deletes tags that other students still use.
- **`cascade = ALL` without `orphanRemoval`** on owned children, which leaves orphan rows when you unlink them.
- **Believing `mappedBy` disables cascade.** It doesn't. Cascade works on both sides.
- **No helper methods** to keep both sides of a bidirectional association in sync.
- **Using `REMOVE` cascade on very large collections** instead of a bulk or database-level delete.
- **Expecting cascade from bulk queries.** JPQL or native `DELETE` ignores it.
- **Calling `save()` and ignoring the returned instance** after a merge.
- **Adding cascade everywhere "to be safe".** Each cascade is a decision about who owns what.