Soft delete and JPA version property
Are you struggling with performance issues in your Spring, Jakarta EE, or Java EE application?
What if there were a tool that could automatically detect what caused performance issues in your JPA and Hibernate data access layer?
Wouldn’t it be awesome to have such a tool to watch your application and prevent performance issues during development, long before they affect production systems?
Well, Hypersistence Optimizer is that tool! And it works with Spring Boot, Spring Framework, Jakarta EE, Java EE, Quarkus, Micronaut, or Play Framework.
So, rather than fixing performance issues in your production system on a Saturday night, you are better off using Hypersistence Optimizer to help you prevent those issues so that you can spend your time on the things that you love!
Introduction
In this article, we are going to see how we can implement a soft delete mechanism when the JPA entity features a version property.
If you are not using a version property for optimistic locking, then you can implement the soft delete strategy, as explained in this article.
Domain Model
Let’s assume we have the following Post, PostDetails, PostComment, and Tag entities that extend the SoftDeletable base class, as illustrated by the following diagram:

Soft delete when the entity has a JPA version property
Now, we want to instruct Hibernate to execute an UPDATE statement that sets the deleted column to the value of true when a Post, PostDetails, PostComments, or Tag entity is deleted.
To accomplish this task, we will need to use the following Hibernate-specific annotations:
@SQLDelete– to override the SQL statements that’s executed whenever an entity is deleted so that we run an UPDATE statement instead.@Loaderand – to override the default query that loads the entity when using afindmethod. Using a custom SQL query, we can filter out the records that have thedeletedcolumn set to the value oftrue.@Where– to override the default query plan when loading the entity using an entity query so that we filter out the records that have thedeletedcolumn set to the value oftrue.
Soft deleting the Tag entity
For the Tag entity, we will use these Hibernate annotations as follows:
@Entity(name = "Tag")
@Table(name = "tag")
@SQLDelete(sql = """
UPDATE tag
SET
deleted = true,
version = version + 1
WHERE
id = ? AND
version = ?
""")
@Loader(namedQuery = "findTagById")
@NamedQuery(name = "findTagById", query = """
select t
from Tag t
where
t.id = ?1 and
t.deleted = false
""")
@Where(clause = "deleted = false")
public class Tag extends SoftDeletable {
⠀
@Id
@GeneratedValue
private Long id;
⠀
@NaturalId
private String name;
⠀
@Version
private short version;
}
Notice that the @SqlDelete query uses the version property as well when deleting the entity so that we can prevent lost updates.
If you wonder why the version property is mapped as a
Short, and not asIntegerorLong, then check out this article about the benefits of using a compact optimistic locking property.
Now, let’s assume we have the following data in the database:
entityManager.persist(new Tag().setName("Java"));
entityManager.persist(new Tag().setName("JPA"));
entityManager.persist(new Tag().setName("Hibernate"));
entityManager.persist(new Tag().setName("Misc"));
Post post = new Post().setTitle("High-Performance Java Persistence");
entityManager.persist(post);
Session session = entityManager.unwrap(Session.class);
post.addTag(session.bySimpleNaturalId(Tag.class).getReference("Java"));
post.addTag(session.bySimpleNaturalId(Tag.class).getReference("Hibernate"));
post.addTag(session.bySimpleNaturalId(Tag.class).getReference("Misc"));
Now, when removing one Tag entity:
Tag miscTag = doInJPA(entityManager -> {
return entityManager
.unwrap(Session.class)
.bySimpleNaturalId(Tag.class)
.getReference("Misc");
});
doInJPA(entityManager -> {
entityManager.remove(miscTag);
});
We can see that Hibernate executes an UPDATE statement that sets the deleted column to the value of true for the tag record that’s just got deleted:
UPDATE
tag
SET
deleted = true,
version = version + 1
WHERE
id = 4 AND
version = 0
When loading the Post entity, we can see that it now has only 2 Tag entities associated since we deleted one entry:
Post post = entityManager.find(Post.class, 1L); assertEquals(2, post.getTags().size());
While the tag record with the value of Misc was not physically deleted, the Post entity can no longer see it since the following SQL query is executed to load the associated Tag entities:
SELECT
pt.post_id,
t1_1.id,
t1_1.deleted,
t1_1.name,
t1_1.version
FROM
post_tag pt
JOIN
tag t1_1 ON t.id = pt.tag_id
WHERE
pt.post_id = 1 AND
(t.deleted = FALSE)
If we try to load the Tag entity that was soft deleted using a find call, we can see that no entity is returned if the Misc tag is still present in the database table, as illustrated by the native SQL query that follows the find call:
assertNull(entityManager.find(Tag.class, miscTag.getId()));
assertEquals(
miscTag.getName(),
entityManager.createNativeQuery("""
SELECT
name
FROM
tag
WHERE
id = :id
""")
.setParameter("id", miscTag.getId())
.getSingleResult()
);
The soft-deleted entities are filtered even when executing JPQL queries, so when loading all Tag entities:
List<Tag> tags = entityManager.createQuery("""
select t
from Tag t
""", Tag.class)
.getResultList();
assertEquals(3, tags.size());
Hibernate executes the following SQL query that filters the tag rows by their deleted column value:
SELECT
t1_0.id,
t1_0.deleted,
t1_0.name,
t1_0.version
FROM
tag t1_0
WHERE
(t1_0.deleted = FALSE)
Soft delete the other entities with a JPA version property
The other entities follow exactly the same soft delete mappings we used for the Tag entity.
For instance, the PostComment entity is mapped as follows:
@Entity(name = "PostComment")
@Table(name = "post_comment")
@SQLDelete(sql = """
UPDATE post_comment
SET
deleted = true,
version = version + 1
WHERE
id = ? AND
version = ?
""")
@Loader(namedQuery = "findPostCommentById")
@NamedQuery(name = "findPostCommentById", query = """
select pc
from PostComment pc
where
pc.id = ?1 and
pc.deleted = false
""")
@Where(clause = "deleted = false")
public class PostComment extends SoftDeletable {
⠀
@Id
@GeneratedValue
private Long id;
⠀
@ManyToOne(fetch = FetchType.LAZY)
private Post post;
⠀
private String review;
⠀
@Version
private short version;
}
The PostDetails is mapped like this:
@Entity(name = "Post")
@Table(name = "post")
@SQLDelete(sql = """
UPDATE post
SET
deleted = true,
version = version + 1
WHERE
id = ? AND
version = ?
""")
@Loader(namedQuery = "findPostById")
@NamedQuery(name = "findPostById", query = """
select p
from Post p
where
p.id = ?1 and
p.deleted = false
""")
@Where(clause = "deleted = false")
public class Post extends SoftDeletable {
⠀
@Id
@GeneratedValue
private Long id;
⠀
private String title;
⠀
@OneToMany(
mappedBy = "post",
cascade = CascadeType.ALL,
orphanRemoval = true
)
private List<PostComment> comments = new ArrayList<>();
⠀
@OneToOne(
mappedBy = "post",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY
)
private PostDetails details;
⠀
@ManyToMany(cascade = {CascadeType.PERSIST, CascadeType.MERGE})
@JoinTable(
name = "post_tag",
joinColumns = @JoinColumn(name = "post_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id")
)
private List<Tag> tags = new ArrayList<>();
⠀
@Version
private short version;
}
And the Post entity is mapped as follows:
@Entity(name = "Post")
@Table(name = "post")
@SQLDelete(sql = """
UPDATE post
SET
deleted = true,
version = version + 1
WHERE
id = ? AND
version = ?
""")
@Loader(namedQuery = "findPostById")
@NamedQuery(name = "findPostById", query = """
select p
from Post p
where
p.id = ?1 and
p.deleted = false
""")
@Where(clause = "deleted = false")
public class Post extends SoftDeletable {
⠀
@Id
@GeneratedValue
private Long id;
⠀
private String title;
⠀
@OneToMany(
mappedBy = "post",
cascade = CascadeType.ALL,
orphanRemoval = true
)
private List<PostComment> comments = new ArrayList<>();
⠀
@OneToOne(
mappedBy = "post",
cascade = CascadeType.ALL,
orphanRemoval = true,
fetch = FetchType.LAZY
)
private PostDetails details;
⠀
@ManyToMany
@JoinTable(
name = "post_tag",
joinColumns = @JoinColumn(name = "post_id"),
inverseJoinColumns = @JoinColumn(name = "tag_id")
)
private List<Tag> tags = new ArrayList<>();
⠀
@Version
private short version;
⠀
public Post addComment(PostComment comment) {
comments.add(comment);
comment.setPost(this);
return this;
}
⠀
public Post removeComment(PostComment comment) {
comments.remove(comment);
comment.setPost(null);
return this;
}
⠀
public Post addDetails(PostDetails details) {
this.details = details;
details.setPost(this);
return this;
}
⠀
public Post removeDetails() {
this.details.setPost(null);
this.details = null;
return this;
}
⠀
public Post addTag(Tag tag) {
tags.add(tag);
return this;
}
}
Assuming we have persisted the following Post entity:
entityManager.persist(
new Post()
.setTitle("High-Performance Java Persistence")
.addDetails(new PostDetails().setCreatedBy("Vlad Mihalcea"))
.addComment(new PostComment().setReview("Excellent!"))
.addComment(new PostComment().setReview("Great!"))
);
When fetching a Post entity along with associated PostDetails child entity and removing the PostDetails:
Post post = entityManager.createQuery("""
select p
from Post p
join fetch p.details
where p.title = :title
""", Post.class)
.setParameter("title", "High-Performance Java Persistence")
.getSingleResult();
assertNotNull(post.getDetails());
post.removeDetails();
We can see that Hibernate executed an UPDATE statement to set the deleted column of the associated post_details record to the value of true:
UPDATE
post_details
SET
deleted = true,
version = version + 1
WHERE
id = 1 AND
version = 0
The same applies to the PostComment entity, so when loading the Post entity along with its associated PsotComment child entities and removing one of the comments:
Post post = entityManager.createQuery("""
select p
from Post p
left join fetch p.comments
where p.title = :title
""", Post.class)
.setParameter("title", "High-Performance Java Persistence")
.getSingleResult();
post.removeComment(post.getComments().get(0));
Hibernate will soft delete the associated post_comment record by executing an UPDATE statement instead of a DELETE:
UPDATE
post_comment
SET
deleted = true,
version = version + 1
WHERE
id = 1 AND
version = 0
That’s it!
If you enjoyed this article, I bet you are going to love my Book and Video Courses as well.
Conclusion
Hibernate is a very flexible JPA provider implementation, allowing you to customize its default behavior based on your application-specific needs.
If your business requirements include the implementation of a soft delete mechanism, then you can achieve this goal with just a few mappings, no matter if the entity uses the JPA version property or not.







I’m planning on using your tests as a test harness to gradually introduce what I’ve done so far to work out where the issue is… would you be interested in me posting back my results if I can pinpoint where it went wrong for me?
I have a Coaching program that I created especially for this kind of situation. Join my program and I will assist you with this issue and other problems that you may bump into.
Thanks, that looks like a good value for money offering, it’s just more than I can afford unfortunately.
I did play about with the repo. The deprecated annotations were swapped out one by one and each broke the tests, which I found interesting and a little worrying. Having replicated the association exactly and still running into the same issue, the only potential thing I’ve thought of that might be an issue is not all of the associated entities are versioned in my model, so that’s my next experiment… thanks for the help though, the repo was very useful and I’ll consider signing up for your coaching program as and when it’s possible.
No worries. If you want to get a feeling of what my content looks like, check out my YouTube channel or the High-Performance Java Persistence video course.
I’ve followed the guidance above, but with Hibernate 6.5.2 (I’ve not tried an earlier version) I get the error:
“No value specified for parameter 2”
I can remove it from the @SQLDelete, and it works when the delete is cascaded when I delete from the “One” side, but it fails then when I try to delete the entity directly saying it can’t bind the second (version) parameter.
My Entity on the Many side is annotated with:
@Entity
@Table(name=”application_user”)
@SQLDelete(sql = “””
UPDATE application_user
SET
deleted_dtm = now(),
version = version + 1
WHERE
application_user_id = ? AND
version = ?
“””)
@HQLSelect(query = “””
select t
from User t
where
t.id = ?1 and
t.deleted is null
“””)
@SQLRestriction(“deleted_dtm is null”)
public class User {
….
Nullable
@ManyToOne(fetch=FetchType.LAZY)
@JoinColumn(name=”organisation_id”, referencedColumnName=”organisation_id”)
private Organisation organisation;
}
(I have moved to the non-deprecated annotations)
And on the other side:
@Entity
@Table(name = “organisation”)
@SQLRestriction(“deleted_dtm IS NULL”)
@SQLDelete(sql = “””
UPDATE organisation
SET
deleted_dtm = now(),
version = version + 1
WHERE
organisation_id = ? AND
version = ?
“””)
@HQLSelect(query = “””
select o
from Organisation o
where
o.id = ?1 and
o.deleted is null
“””)
public class Organisation {
@OneToMany(mappedBy = “organisation”, cascade = {CascadeType.ALL}, fetch = FetchType.LAZY, orphanRemoval = true)
private List organisationUsers = new ArrayList<>();
}
I’m not sure if my mapping is wrong, as where it says:
“The PostDetails is mapped like this:”
The Post entity is shown, rather than the PostDetails entity, so I may be missing something.
Check out this test case on GitHub that works just fine and compare it to your mapping:
https://github.com/vladmihalcea/high-performance-java-persistence/blob/ccc9643317ae1fb6a45fa1693eaefcc46916e4f4/core/src/test/java/com/vladmihalcea/hpjp/hibernate/mapping/softdelete/SoftDeleteVersionTest.java#L269
Thanks, I’ll give it a go!
You’re welcome.