@Modifying

Marks a @Query as an UPDATE, DELETE or INSERT so Spring Data executes it as a bulk statement and returns the affected row count.

Signature
@Modifying(flushAutomatically = false, clearAutomatically = false)
@Query("update Order o set o.status = :s where o.id in :ids")
  • Added in Spring Boot Spring Data JPA 1.0

Parameters

ParameterTypeDescription
$flushAutomaticallyoptionalboolean= falseFlush pending changes in the persistence context to the database before running the statement.
$clearAutomaticallyoptionalboolean= falseClear the persistence context after the statement so later reads load fresh state instead of stale managed entities.

Return value

n/aTells Spring Data the @Query changes data, so it is run with executeUpdate() and the method returns the number of rows affected (int or void).

Errors

Without a surrounding transaction the call fails with InvalidDataAccessApiUsageException wrapping TransactionRequiredException. Without @Modifying, an UPDATE or DELETE query fails because it is executed as a select.

A @Query on its own is run as a select. To run an UPDATE, DELETE or native INSERT, add @Modifying; Spring Data then calls executeUpdate() and returns the number of affected rows. Declare the return type as int when you care about the count, otherwise void.

Why use a bulk statement

Loading 10,000 expired sessions to delete them one by one runs 10,001 statements and fills memory. A single delete from Session s where s.expiresAt < :cutoff does the same work in one round trip. The same applies to status changes such as closing every overdue invoice.

It needs a transaction

Query methods you declare on a repository interface are not transactional by default; only the inherited CRUD methods are. Calling a modifying query with no transaction around it fails with a TransactionRequiredException wrapped in Spring's InvalidDataAccessApiUsageException. Either call it from a @Transactional service method, or put @Transactional on the repository method itself.

It bypasses the persistence context

A bulk statement goes straight to the database. Hibernate does not update entities it has already loaded, does not run lifecycle callbacks such as @PreUpdate, does not cascade, does not bump @Version and does not fill @LastModifiedDate. Each of these surprises people in a different way:

  • An entity loaded before the update still shows the old value if you read it again in the same transaction. clearAutomatically = true empties the persistence context after the statement so later reads hit the database.
  • Clearing also discards unsaved changes to managed entities. flushAutomatically = true writes those changes first, so use the two together when the same transaction also edits entities.
  • Concurrent edits are not detected unless the query increments the version itself: set o.status = :s, o.version = o.version + 1.
  • Audit columns stay stale unless the query sets them: o.updatedAt = :now.
@Modifying(flushAutomatically = true, clearAutomatically = true)
@Query("update Invoice i set i.status = 'OVERDUE' where i.dueDate < :today and i.status = 'OPEN'")
int markOverdue(@Param("today") LocalDate today);

When not to use it

If the change needs domain rules, events or cascades, load the entities and change them through their methods. Reserve @Modifying for set-based maintenance where those rules do not apply, and check the returned count: zero rows updated often means the id or a precondition was wrong, and the caller should hear about it.

Example

Example · java
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
import java.time.Instant;
import java.time.LocalDate;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;

@Entity
class Invoice {
    @Id
    private Long id;
    private String status;
    private LocalDate dueDate;
    private Instant updatedAt;
    @Version
    private Long version;
    protected Invoice() { }
}

public interface InvoiceRepository extends JpaRepository<Invoice, Long> {

    // keeps version and audit column honest, because the bulk update skips both
    @Transactional
    @Modifying(flushAutomatically = true, clearAutomatically = true)
    @Query("""
            update Invoice i
               set i.status = 'OVERDUE', i.updatedAt = :now, i.version = i.version + 1
             where i.status = 'OPEN' and i.dueDate < :today
            """)
    int markOverdue(@Param("today") LocalDate today, @Param("now") Instant now);

    @Transactional
    @Modifying
    @Query("delete from Invoice i where i.status = 'VOID' and i.updatedAt < :cutoff")
    int purgeVoided(@Param("cutoff") Instant cutoff);
}

When to use it

  • Deleting expired tokens or sessions in a nightly job with one statement.
  • Holding a seat or stock item with a conditional update that only one concurrent request can win.
  • Marking every overdue invoice in one statement while still bumping its version and update time.

Frequently asked questions

Why do I get TransactionRequiredException from a @Modifying query?

Query methods declared on your repository are not transactional by default. Call the method from a @Transactional service method or annotate the repository method with @Transactional.

Why does my entity still show the old value after a bulk update?

The update ran in the database but the entity in the persistence context was loaded before it. Use clearAutomatically = true, or reload the entity in a new transaction.

Is a derived deleteBy method the same as a @Modifying delete?

No. A derived deleteBy method loads each entity and removes it, so cascades and callbacks run but it costs one statement per row. A @Modifying delete is one statement and skips all of that.

More examples

A nightly clean-up job

The transaction comes from the scheduled method, so the repository method needs no annotation of its own. Scheduling also needs @EnableScheduling on a configuration class. Logging the count makes it obvious in production when the job stops deleting anything.

Example · java
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import java.time.Duration;
import java.time.Instant;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

@Entity
class PasswordResetToken {
    @Id
    private String token;
    private Instant expiresAt;
    protected PasswordResetToken() { }
}

interface ResetTokenRepository extends JpaRepository<PasswordResetToken, String> {
    @Modifying
    @Query("delete from PasswordResetToken t where t.expiresAt < :cutoff")
    int deleteExpired(@Param("cutoff") Instant cutoff);
}

@Component
public class TokenCleanup {
    private static final Logger log = LoggerFactory.getLogger(TokenCleanup.class);
    private final ResetTokenRepository tokens;

    public TokenCleanup(ResetTokenRepository tokens) { this.tokens = tokens; }

    @Scheduled(cron = "0 15 3 * * *")
    @Transactional
    public void purge() {
        int removed = tokens.deleteExpired(Instant.now().minus(Duration.ofDays(1)));
        log.info("Removed {} expired reset tokens", removed);
    }
}

Checking the row count

The WHERE clause makes the update conditional, and the database applies it atomically, so two users racing for the same seat cannot both win. Zero rows means someone else got there first.

Example · java
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

@Entity
class Seat {
    @Id
    private Long id;
    private Long heldBy;
    protected Seat() { }
}

interface SeatRepository extends JpaRepository<Seat, Long> {
    @Modifying
    @Query("update Seat s set s.heldBy = :user where s.id = :id and s.heldBy is null")
    int hold(@Param("id") Long seatId, @Param("user") Long userId);
}

class SeatTakenException extends RuntimeException {
    SeatTakenException(Long id) { super("Seat " + id + " is already held"); }
}

@Service
public class BookingService {
    private final SeatRepository seats;

    public BookingService(SeatRepository seats) { this.seats = seats; }

    @Transactional
    public void hold(Long seatId, Long userId) {
        if (seats.hold(seatId, userId) == 0) {
            throw new SeatTakenException(seatId);
        }
    }
}

Native insert that ignores duplicates

PostgreSQL's ON CONFLICT DO NOTHING returns 1 for a new message and 0 for a duplicate, giving a cheap idempotency check for message consumers without an exception on redelivery.

Example · java
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;

@Entity
class ProcessedMessage {
    @Id
    private String messageId;
    protected ProcessedMessage() { }
}

public interface ProcessedMessageRepository extends JpaRepository<ProcessedMessage, String> {

    @Transactional
    @Modifying
    @Query(value = "insert into processed_message (message_id) values (:id) on conflict do nothing",
           nativeQuery = true)
    int markProcessed(@Param("id") String messageId);
}

Discussion

  • Be the first to comment on this lesson.