Hibernate Delete Entity: remove(), Bulk Delete and FK Errors

Delete an entity in Hibernate 7 with em.remove() after find() or getReference(), fix the detached entity error, and use a JPQL bulk delete without leaving stale objects or hitting foreign key errors.

Jakarta_EE

To delete an entity in Hibernate, we load it into the persistence context, the set of objects the EntityManager tracks, and pass it to EntityManager.remove(), and Hibernate sends the SQL DELETE when the transaction commits. The remove() call handles one entity at a time and runs its lifecycle callbacks and cascades.

We delete entities whenever a user leaves or data expires, for example when a newsletter service drops the subscribers who never confirmed their email address. To remove many rows with one statement, we run a JPQL delete query instead, which skips the callbacks and cascades.

The following example shows the ways to delete a Subscriber, from remove() on a loaded entity to a bulk delete.

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "newsletter_id")
private Newsletter newsletter;

@PreRemove
void beforeRemove() {
  System.out.println("@PreRemove " + name);
}
Subscriber alex = em.find(Subscriber.class, alexId);   // select ... from Subscriber s1_0 where s1_0.id=?
em.remove(alex);                                       // delete from Subscriber where id=? (at commit)

em.remove(em.getReference(Subscriber.class, alexId));  // same SELECT and DELETE

int deleted = em.createQuery("delete from Subscriber s where s.confirmed = false")
    .executeUpdate();                                  // delete from Subscriber s1_0 where s1_0.confirmed=false
                                                       // deleted = 2

em.remove(detached);                                   // IllegalArgumentException

Notice that a detached object needs a managed copy before remove(), and that the bulk delete returns the number of deleted rows without loading any entity.

Next, we see how Hibernate deletes an entity, and then the cases that fail in real apps, such as stale objects in memory after a bulk delete and a parent row that still has child rows.

1. How Hibernate Deletes an Entity

The method em.remove() works only on a managed entity, an object that the current EntityManager loaded or saved and still tracks. The call does not send SQL. It changes the entity’s state to removed and runs the @PreRemove callback, and the DELETE waits for the next flush, which happens at commit or before a query that needs it.

Three states from left to right: MANAGED after em.find loaded Alex, with em.contains true and the row in the table; em.remove(alex) moves it to REMOVED, where @PreRemove runs, em.contains is false, em.find returns null and no SQL is sent yet; flush or commit moves it to DELETED, sending delete from Subscriber where id=?, and the Java object still has id 2 and name Alex; a green arrow from REMOVED back to MANAGED says em.persist(alex) before commit cancels the delete; a red box says em.remove on a detached entity throws IllegalArgumentException and the fix is em.merge or em.getReference
remove() only marks the entity. The row is deleted at flush, and until then persist() can undo it.

Two facts in the picture surprise many developers.

  • The DELETE runs at flush, not at the remove() call. Before the commit, the row is still in the table, but em.find() already returns null for it.
  • After the commit, the alex object still exists in memory with its id and name. It is no longer connected to any row.

There are four ways to send the DELETE. They differ in the number of statements and in what they skip.

OptionSQL to delete Alex and BrianCascades@PreRemovePersistence context
find() or a query, then remove()1 SELECT + 2 DELETEAppliedRunsUpdated
getReference(), then remove()1 SELECT + 1 DELETE per row (only the DELETE when the entity has no callback)AppliedRunsUpdated
JPQL delete query1 DELETESkippedSkippedNot updated
Hibernate Session.remove()Same as em.remove()AppliedRunsUpdated

We use remove() when the entity has callbacks, cascades or is already loaded, and a bulk delete when we remove many rows and nothing else needs to react.

2. Deleting Entities Example

The following example is a newsletter service built with Hibernate 7.4.11, Java 25 and an in-memory H2 database.

2.1. Newsletter and Subscriber Entities

A newsletter “Java Weekly” has three subscribers. Lokesh confirmed his email address, whereas Alex and Brian did not. The Subscriber table holds the foreign key newsletter_id, so a subscriber row points to its newsletter row.

The newsletter side is a plain @OneToMany without any cascade, so deleting a newsletter does not touch its subscribers.

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String name;

@OneToMany(mappedBy = "newsletter")
private List<Subscriber> subscribers = new ArrayList<>();

Subscriber owns the foreign key and has a @PreRemove method. A lifecycle callback like this runs inside remove(), so the printed line shows us which delete options call it.

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String name;

private boolean confirmed;

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "newsletter_id")
private Newsletter newsletter;

@PreRemove
void beforeRemove() {
  System.out.println("@PreRemove " + name);
}

2.2. Deleting a Loaded Entity With remove()

The usual way is to load the entity with em.find() and pass it to remove() inside a transaction. The helper emf.runInTransaction() opens an EntityManager, begins the transaction, runs our code and commits.

emf.runInTransaction(em -> {
  Subscriber alex = em.find(Subscriber.class, alexId);   // select ... from Subscriber s1_0 where s1_0.id=?
  em.remove(alex);                                       // prints "@PreRemove Alex", no SQL
  boolean managed = em.contains(alex);                   // false
  Subscriber found = em.find(Subscriber.class, alexId);  // null, no SQL
});                                                      // delete from Subscriber where id=?

A removed entity can still be saved again in the same transaction. Calling persist() on it makes it managed again, and Hibernate sends no DELETE.

em.remove(alex);
em.persist(alex);        // the commit sends no DELETE, all 3 subscribers stay

2.3. Deleting by Id With getReference()

When we know only the id, for example from a request DELETE /subscribers/2, em.getReference() returns a proxy, a placeholder object that holds only the id. Whether Hibernate still loads the row depends on the entity.

em.remove(em.getReference(Subscriber.class, alexId));
// select ... from Subscriber s1_0 where s1_0.id=?
// delete from Subscriber where id=?

em.remove(em.getReference(Newsletter.class, kotlinId));   // an empty newsletter
// delete from Newsletter where id=?

Subscriber has a @PreRemove method, and the callback needs a real object with its fields, so Hibernate loads the row first. Newsletter has no callback, so Hibernate sends only the DELETE. A reference saves the SELECT only for entities without callbacks.

2.4. Removing a Detached Entity

A detached entity was loaded by an EntityManager that is now closed, for example an object returned by a service method. Passing it to remove() in a new transaction fails.

Subscriber detached = emf.callInTransaction(em -> em.find(Subscriber.class, alexId));

emf.runInTransaction(em -> em.remove(detached));
java.lang.IllegalArgumentException: org.hibernate.DetachedObjectException: Given entity is not associated with the persistence context

The new EntityManager can delete only an object it manages, so we give it its own copy first. Both fixes delete the row.

emf.runInTransaction(em -> em.remove(em.merge(detached)));
// select ... from Subscriber s1_0 where s1_0.id=?
// delete from Subscriber where id=?

emf.runInTransaction(em -> em.remove(em.getReference(Subscriber.class, detached.getId())));

The method merge() loads the row and copies the detached state onto the managed object, whereas getReference() is enough when we only want the row gone.

2.5. Bulk Delete With JPQL

A JPQL delete statement removes all matching rows with one SQL statement. For example, a nightly job removes every subscriber who did not confirm the email address. The method executeUpdate() returns the number of deleted rows.

emf.runInTransaction(em -> {
  int deleted = em.createQuery("delete from Subscriber s where s.confirmed = false")
      .executeUpdate();          // delete from Subscriber s1_0 where s1_0.confirmed=false
                                 // deleted = 2
});

The console shows no “@PreRemove” line. Removing the same two subscribers one by one costs three statements and runs both callbacks.

em.createQuery("from Subscriber s where s.confirmed = false", Subscriber.class)
    .getResultList()             // select ... from Subscriber s1_0 where s1_0.confirmed=false
    .forEach(em::remove);        // @PreRemove Alex, @PreRemove Brian
                                 // delete from Subscriber where id=?  (twice)

The gap grows with the row count, because the loop sends one DELETE per row, whereas the query always sends one statement. For large deletes we can also turn on JDBC batching, which sends the loop’s DELETE statements in groups.

2.6. Bulk Delete and the Persistence Context

A bulk delete changes the table itself and does not touch the objects that the EntityManager has already loaded. The persistence context still holds them, and Hibernate keeps returning them from memory.

The JPQL statement delete from Subscriber s where s.confirmed = false goes to the Subscriber table, where Lokesh is kept and Alex and Brian are deleted, and executeUpdate returns 2; a dashed arrow to the persistence context is marked skipped, so it still holds Subscriber 2 Alex and Newsletter 1 Java Weekly with subscribers Lokesh, Alex and Brian; below, in the same EntityManager, em.find returns the stale Alex with no SQL, getSubscribers().size() returns 3 while the table has 1, em.remove(alex) fails at commit with Unexpected row count, and after em.clear() em.find returns null
The query deletes the rows but not the objects in memory. Only clear() or a new EntityManager drops them.
emf.runInTransaction(em -> {
  Subscriber alex = em.find(Subscriber.class, alexId);
  em.createQuery("delete from Subscriber s where s.confirmed = false").executeUpdate();

  boolean managed = em.contains(alex);                     // true
  Subscriber cached = em.find(Subscriber.class, alexId);   // returns alex, no SQL

  em.clear();
  Subscriber gone = em.find(Subscriber.class, alexId);     // select ..., null
});

The stale object causes an error when we use it. For example, when we remove Alex after the bulk delete has deleted his row, the commit fails.

Subscriber alex = em.find(Subscriber.class, alexId);
em.createQuery("delete from Subscriber s where s.confirmed = false").executeUpdate();
em.remove(alex);     // commit sends delete from Subscriber where id=? and fails
jakarta.persistence.RollbackException: Error while committing the transaction [Unexpected row count (expected row count 1 but was 0) [delete from Subscriber where id=?] for entity [com.howtodoinjava.hibernate.delete.Subscriber with id '2']]

The whole transaction rolls back, so the bulk delete is undone too. We run a bulk delete at the start of a transaction, or call em.clear() right after it, before we read any affected entity again.

2.7. Deleting a Newsletter That Still Has Subscribers

The database refuses to delete a row while another row still points to it with a foreign key. Our Newsletter mapping has no cascade, so remove() deletes only the newsletter row.

emf.runInTransaction(em -> em.remove(em.find(Newsletter.class, newsletterId)));
// delete from Newsletter where id=?
jakarta.persistence.RollbackException: Error while committing the transaction [could not execute statement [Referential integrity constraint violation: "FKIM1JGVWASLATCSEM95PKFC2FC: PUBLIC.SUBSCRIBER FOREIGN KEY(NEWSLETTER_ID) REFERENCES PUBLIC.NEWSLETTER(ID) (CAST(1 AS BIGINT))"; SQL statement:
delete from Newsletter where id=? [23503-252]] [delete from Newsletter where id=?]]

The cause is an org.hibernate.exception.ConstraintViolationException. The bulk query delete from Newsletter fails with the same violation, and it fails at once, at executeUpdate().

We delete the child rows first, then the parent, in one transaction.

Newsletter table with id 1 Java Weekly, and Subscriber table with Lokesh, Alex and Brian whose newsletter_id 1 references it; on the left in red, deleting the parent first with em.remove or delete from Newsletter sends delete from Newsletter where id=? and fails with ConstraintViolationException, Referential integrity constraint violation, and all 4 rows stay; on the right in green, step 1 delete from Subscriber where newsletter_id=? and step 2 delete from Newsletter where id=? delete everything in one transaction; a note says cascade REMOVE or orphanRemoval makes em.remove send the child deletes and a JPQL bulk delete never cascades
Each subscriber row points to the newsletter row, so the subscribers must go first.
emf.runInTransaction(em -> {
  Newsletter javaWeekly = em.find(Newsletter.class, newsletterId);
  em.createQuery("delete from Subscriber s where s.newsletter = :newsletter")
      .setParameter("newsletter", javaWeekly)
      .executeUpdate();          // delete from Subscriber s1_0 where s1_0.newsletter_id=?
  em.remove(javaWeekly);         // delete from Newsletter where id=?
});

When the children always belong to the parent, the mapping can delete them for us. CascadeType.REMOVE makes em.remove(javaWeekly) remove every subscriber first, and orphanRemoval also deletes a subscriber that is taken out of the collection. Both work only through remove(), never through a JPQL delete.

The complete project on GitHub runs every case of this section and prints the SQL, and its 24 JUnit tests check each result (mvn -q compile exec:java, mvn test).

3. Deleting Entities FAQs

3.1. What Replaced Session.delete() in Hibernate 7?

Hibernate 6.0 deprecated Session.delete(), and Hibernate 7 removed it. We call remove(), on the Session or on the EntityManager. In Hibernate 7, Session extends EntityManager, so both calls run the same code.

session.delete(subscriber);                          // Hibernate 5: does not compile in 7.x

Session session = em.unwrap(Session.class);
session.remove(session.find(Subscriber.class, alexId));   // delete from Subscriber where id=?

3.2. Can Session.remove() Delete a Detached Entity?

No. In Hibernate 7.4, Session.remove() throws the same IllegalArgumentException as em.remove(), with the message “Given entity is not associated with the persistence context”. We use the merge() or getReference() fix from section 2.4.

3.3. How Do I Delete an Entity by Id Without Loading It?

We have two options, and they behave differently when the row does not exist.

CodeSQLRow with id 99 does not exist
em.remove(em.getReference(Subscriber.class, 99L))SELECT (because of the callback), then DELETEEntityNotFoundException: No row with the given identifier exists
em.createQuery(“delete from Subscriber s where s.id = :id”)One DELETEexecuteUpdate() returns 0
em.remove(em.find(Subscriber.class, 99L))SELECTIllegalArgumentException: Entity may not be null

The JPQL query never loads the row and reports a missing row through its return value, so it is the cheapest option when no callback has to run.

3.4. Does a Bulk Delete See Entities I Have Not Flushed Yet?

Yes. With the default flush mode AUTO, Hibernate flushes pending changes before it runs the query. A subscriber persisted in the same transaction is inserted first and then deleted.

javaWeekly.addSubscriber(chris);         // new Subscriber("Chris", false)
em.persist(chris);
int deleted = em.createQuery("delete from Subscriber s where s.confirmed = false")
    .executeUpdate();                    // insert into Subscriber ..., then the delete
                                         // returns 3

So we do not need em.flush() before the query. The stale objects from section 2.6 are a separate problem, and flush() does not fix them.

3.5. Should I Use Soft Delete Instead?

A soft delete keeps the row and sets a flag column instead of running a DELETE. Hibernate 6.4 added @SoftDelete for it, and every query then skips the flagged rows. We choose it when deleted data must stay for audits or for an undo button, for example when a subscriber leaves but we keep the history. It also avoids the foreign key error, because no row disappears. For data that must leave the database, such as a user’s request to erase personal data, we still need a real DELETE.

4. Conclusion

The method em.remove() deletes one managed entity at flush and runs its callbacks and cascades, while a JPQL delete removes many rows with one statement and skips both.

We remove detached objects through merge() or getReference(), and we clear the persistence context after a bulk delete. Child rows go before their parent, either with an explicit delete or with a cascade.

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.