Efficient Soft Delete in Spring Boot JPA: Implementation Guide & Best Practices

In this post, we’ll explore how to implement an efficient soft delete strategy using Spring Boot and JPA, along with practical best practices to avoid common pitfalls.


Prerequisites

This is the list of all the prerequisites:

  • Spring Boot 4 or later
  • Maven 3.9+
  • Java 21 or later
  • IntelliJ IDEA, Visual Studio Code, or another IDE
  • Postman / insomnia or any other API testing tool.
  • H2 / PostgreSQL (depending on your choice of database)

Overview

What Is Soft Delete?

Soft delete is a technique where a record is not actually removed from the database. Instead, a flag (e.g., deleted = true) or a timestamp (deleted_at) is used to indicate that the record should be treated as deleted.

  • SQL Operation
    Hard Delete: DELETE FROM users WHERE id = 1
    Soft Delete: UPDATE users SET deleted_at = NOW() WHERE id = 1
  • Data Recoverability
    Hard Delete: No (data is permanently removed)
    Soft Delete: Yes (data can be restored)
  • Audit Trail
    Hard Delete: Lost
    Soft Delete: Preserved
  • Referential Integrity Risk
    Hard Delete: High
    Soft Delete: Low
  • Query Complexity
    Hard Delete: Low
    Soft Delete: Medium

Why Use Soft Delete in Spring Boot JPA

Soft delete is a strong fit when:

  • Regulatory compliance requires data retention (GDPR right-to-erasure being a notable exception — more on that later).
  • Audit trails are required to know when and by whom a record was removed.
  • Data recovery is a business requirement (e.g., restoring an accidentally deleted account).
  • Referential integrity must be maintained across related entities.

It is not ideal for high-volume, high-churn tables where storage cost and query performance are critical, and where historical data has no value.

Let’s code

We’ll start by creating a simple Spring Boot project from start.spring.io, with the following dependencies: Spring Web, Lombok, PostgreSQL Database, Spring Data JPA, and Validation.

Choosing the Right Soft Delete Strategy

Before writing any Java code, it’s important to start with the right soft delete strategy.

A key design decision is how to represent the deletion state in your database. In practice, there are two common approaches:

  • A boolean flag (deleted = true/false)
  • A timestamp (deleted_at = NULL / NOT NULL)

1. Boolean Flag (deleted)

This approach uses a simple flag to indicate whether a record is deleted:

ALTER TABLE book ADD COLUMN deleted BOOLEAN NOT NULL DEFAULT FALSE;

Active records are then retrieved with:

SELECT * FROM book WHERE deleted = false;

While this method is straightforward and easy to implement, it comes with several limitations:

  • It does not capture when the deletion occurred
  • It makes data retention and cleanup strategies harder to implement
  • It offers limited value for auditing and debugging

2. Timestamp (deleted_at)

An alternative approach is to store the exact time a record is deleted:

ALTER TABLE book ADD COLUMN deleted_at TIMESTAMP NULL;

With this strategy, active records are simply those where:

deleted_at IS NULL

It offers:

  • Greater flexibility in handling data lifecycle
  • Improved observability, thanks to precise deletion timestamps
  • Better alignment with real-world requirements like auditing and compliance

In contrast, while a boolean flag may be sufficient for very simple use cases, it often becomes restrictive as the application grows, leading to future refactoring.

In this post, we adopt the deleted_at strategy as it provides a more robust and maintainable foundation for production systems.

Implementing Soft Delete in JPA

Assume we have the following Author and Book entities that inherit from the SoftDeletable base class, as shown in the diagram below:

Create a Base Soft Delete Entity

Avoid duplicating logic across entities.

@Getter
@Setter
@MappedSuperclass
public abstract class SoftDeletable implements Serializable {

    @Serial
    private static final long serialVersionUID = 1L;

    @Column(name = "deleted_at")
    private LocalDateTime deletedAt;
}

Basic Implementation with @SQLDelete and @SQLRestriction

In Spring Boot (with Hibernate 6 / Spring Boot 3+), two annotations are available to transparently support soft deletion: @SQLDelete and @SQLRestriction. These annotations allow you to override the default delete operation and automatically filter out logically deleted entities from query results.

Author Entity

@Data
@EqualsAndHashCode(callSuper = true)
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "author")
@SQLDelete(sql = "UPDATE author SET deleted_at = now() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class Author extends SoftDeletable {

    @Serial
    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    private Integer id;

    @Column(name = "lastname", nullable = false, length = 100)
    private String lastname;

    @Column(name = "firstname", length = 255)
    private String firstname;

    @OneToMany(mappedBy = "author")
    private Set<Book> books = new HashSet<>();
}

Book Entity


@Data
@EqualsAndHashCode(callSuper = true)
@AllArgsConstructor
@NoArgsConstructor
@Entity
@Table(name = "book")
@SQLDelete(sql = "UPDATE book SET deleted_at = now() WHERE id = ?")
@SQLRestriction("deleted_at IS NULL")
public class Book extends SoftDeletable {

    @Serial
    private static final long serialVersionUID = 1L;

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    @Column(name = "id", nullable = false)
    private Integer id;

    @Column(name = "title", nullable = false, length = 100)
    private String title;

    @Column(name = "isbn")
    private String isbn;

    @Column(name = "description")
    private String description;

    @Column(name = "page")
    private Integer page;

    @Column(name = "price")
    private BigDecimal price;

    @ManyToOne
    @JsonIgnoreProperties("books")
    private Author author;
}

What these annotations do:

  • @SQLDelete overrides the SQL Hibernate generates when you call repository.delete(entity) or repository.deleteById(id). Instead of issuing a DELETE, it runs your custom UPDATE.
  • @SQLRestriction appends the given clause to every SELECT Hibernate generates for this entity, effectively hiding soft-deleted rows from all standard queries.

Hibernate 5 / Spring Boot 2.x: Replace @SQLRestriction("deleted_at IS NULL") with @Where(clause = "deleted_at IS NULL"). The clause string stays the same.

Repository Layer

@Repository
public interface AuthorRepository extends JpaRepository<Author, Long> {

}

@Repository
public interface BookRepository extends JpaRepository<Book, Long> {

}

Because of @Where, all default queries automatically exclude deleted records.

Handling Relationships

Soft delete does NOT cascade automatically. If you soft delete an Author, their Book records remain active in the database, creating inconsistent state.

// DANGEROUS: Books remain active while Author is "deleted"
authorRepository.delete(authorId);  
// Author.deleted_at = now()
// Book.deleted_at still = NULL → Books appear active!

The solution is to implement a manual cascade within the service layer

@Service
@Transactional
public class AuthorServiceImpl implements AuthorService {

    private final AuthorRepository repository;
    private final BookRepository bookRepository;
    private final AuthorMapper mapper;

    public AuthorServiceImpl(AuthorRepository repository, BookRepository bookRepository, AuthorMapper mapper) {
        this.repository = repository;
        this.bookRepository = bookRepository;
        this.mapper = mapper;
    }

    @Override
    public void delete(Long id) {
        Author author = repository.findById(id)
                .orElseThrow(() -> new ResourceNotFoundException("Author", id));

        // 1. Cascade to relationships FIRST
        bookRepository.softDeleteByAuthor(author, LocalDateTime.now());

        // 2. Then delete parent
        repository.delete(author);
    }
}

@Repository
public interface BookRepository extends JpaRepository<Book, Long> {

    @Modifying
    @Query("UPDATE Book b SET b.deletedAt = :now WHERE b.author = :author AND b.deletedAt IS NULL")
    void softDeleteByAuthor(@Param("author") Author author, @Param("now") LocalDateTime now);
}

Another more advanced approach is to implement a database trigger to handle the cascade automatically.

Custom Queries: Fetching Deleted Records

By default, annotations like @Where(clause = "deleted_at IS NULL") ensure that all standard JPA queries automatically exclude soft-deleted records. While this behavior is ideal for most application scenarios, there are cases—such as admin dashboards, audit tools, or data recovery workflows—where accessing soft-deleted data is necessary.

Because @Where is applied at the entity level and cannot be toggled dynamically, the only way to include soft-deleted records in query results is by defining explicit custom queries that bypass this restriction.

@Repository
public interface BookRepository extends JpaRepository<Book, Long> {
    
    // Standard query - automatically filtered by @SQLRestriction
    List<Book> findByAuthorId(Long authorId);  // Active only
    
    // Include deleted records via native query
    @Query(value = "SELECT * FROM book WHERE author_id = :authorId", 
           nativeQuery = true)
    List<Book> findAllByAuthorIdIncludingDeleted(@Param("authorId") Long authorId);
    
    // Using JPQL without restrictions
    @Query("SELECT b FROM Book b WHERE b.author.id = :authorId")
    List<Book> findAllByAuthorIdIncludingDeletedJPQL(@Param("authorId") Long authorId);
    
    // Fetch only deleted records
    @Query("SELECT b FROM Book b WHERE b.deletedAt IS NOT NULL")
    List<Book> findAllDeleted();
    
    // Admin dashboard queries
    @Query("SELECT COUNT(b) FROM Book b WHERE b.deletedAt IS NOT NULL")
    long countDeletedBooks();
}

Common Pitfalls

Pitfall 1: deleteAllInBatch() Bypasses @SQLDelete

deleteAllInBatch() executes a single bulk DELETE FROM book statement, which bypasses the entity lifecycle and does not invoke @SQLDelete.

Pitfall 2: GDPR and the Right to Erasure

Soft delete merely hides personal data — it doesn’t remove it. Under GDPR’s right to erasure, data must be fully deleted or irreversibly anonymized. To comply, implement a dedicated erasure process that anonymizes personally identifiable information (PII) before applying soft deletion.

Pitfall 3: Lazy Collections and Soft-Deleted Children

Author.books uses FetchType.LAZY by default. Because @SQLRestriction is on Book, deleted books are excluded when Hibernate loads the collection via JPQL. However, if you access books through a native SQL join, deleted books may appear. Always verify your fetch strategy when mixing native queries with soft-deleted relationships.

Pitfall 4: Missing Index on deleted_at

Without proper indexing, queries that filter on WHERE deleted_at IS NULL will increasingly trigger full table scans as the dataset grows, leading to noticeable performance degradation over time.

Pitfall 5: Mixing deleted and deleted_at

Some implementations use both a deleted boolean flag and a deleted_at timestamp, which introduces redundancy and the risk of inconsistency—for example, deleted = false while deleted_at IS NOT NULL. To avoid these issues, it’s better to adopt a single strategy, preferably relying on deleted_at alone.

Pitfall 6: Table Growth Over Time

Soft delete prevents physical removal of data, so over time tables grow significantly, indexes become heavier, and query performance degrades. To mitigate this, implement a cleanup strategy — such as scheduled jobs that permanently delete outdated records and archiving older data into separate tables.

Pitfall 7: Forgetting to Handle Restore Logic

Soft delete is only half the story — being able to restore data is often just as important. Without a clear restore mechanism, records can become “logically lost,” making admin workflows cumbersome. To address this, provide explicit restore operations, such as a query that resets the deletion marker (e.g., setting deletedAt back to NULL for a given record).

Testing

Start the application and hit the endpoints to verify everything is working as expected.

Here’s the generated SQL for this request. As you can see, the condition deleted_at IS NULL is automatically appended to the query:

Hibernate: 
    select
        b1_0.id,
        a1_0.id,
        a1_0.deleted_at,
        a1_0.firstname,
        a1_0.lastname,
        b1_0.deleted_at,
        b1_0.description,
        b1_0.isbn,
        b1_0.page,
        b1_0.price,
        b1_0.title 
    from
        book b1_0 
    left join
        author a1_0 
            on a1_0.id = b1_0.author_id 
            and (a1_0.deleted_at IS NULL) 
    where
        b1_0.id = ? 
        and (b1_0.deleted_at IS NULL)

Now, we’re going to try deleting this book record.

The deleted_at column is now populated in the database, and when you try calling the getById endpoint again, it returns a “not found” response.

This happens because the entity is using a soft delete mechanism, where records are not physically removed but are instead excluded from queries via the deleted_at IS NULL condition. Once the record is marked as deleted, it no longer matches the default query filters, so JPA behaves as if it doesn’t exist.

Conclusion

🏁 Well done !!. In this post, we implemented soft delete using Spring Boot JPA.

Implementing soft delete involves more than just introducing a deleted_at column. A production-ready approach requires careful design decisions and several additional considerations to ensure correctness, performance, and maintainability.

The complete source code is available on GitHub.

Support me through GitHub Sponsors.

Thank you for reading!! See you in the next post.

References

👉 Link to Medium blog

Related Posts