A repository interface rarely carries more than three annotations, and still they decide whether your change ever reaches the database. @Transactional(readOnly = true), @Repository and @Modifying look like declarations for the next reader. Two of them reach deep into Hibernate, one does nothing at all.
This article walks the whole path along a single running example: a nightly job that is supposed to end expired promotions and reports every morning that all went well, while the shop keeps showing the promo price. At the end there is a solution that holds, plus the honest answer to when the whole effort is not worth it.
This is exclusively about the annotations on the repository interface. The classic proxy pitfalls of @Transactional (self-invocation, checked exceptions, LazyInitializationException) are their own topic and live in @Transactional in Spring: the proxy and its pitfalls.
Contents
- The scene: the job that does nothing
- Prerequisites and versions
- Why a repository has a transaction at all
- What readOnly actually switches on
- The resolution: why the UPDATE never came
- On PostgreSQL it fails more loudly
- @Repository on the interface does nothing
- The second attempt: @Modifying
- The stale persistence context
- What else a bulk update skips
- The turning point: three calls, three transactions
- The complete solution
- The annotations at a glance
- Does readOnly belong on the repository or the service?
- When readOnly brings nothing
- FAQ
- Conclusion
- Sources
The scene: the job that does nothing
An online shop sells tools: drills, jigsaws, cordless screwdrivers, plus accessories and spare parts. Promotions run for a limited time. Each promotion hangs off an article number and has an end date, plus a flag telling whether it is still running. At quarter past three in the morning a job runs that ends the expired promotions so the cordless screwdriver is back at its regular price in the morning. The business logic is modest, the entity accordingly small.
@Entity
public class PromoPrice {
@Id
@GeneratedValue
private Long id;
private String articleNumber;
private LocalDate endsOn;
private boolean running;
protected PromoPrice() {
}
public boolean isRunning() {
return running;
}
public void setRunning( boolean running ) {
this.running = running;
}
public LocalDate getEndsOn() {
return endsOn;
}
public String getArticleNumber() {
return articleNumber;
}
}The repository looks the way repositories look in many projects. Three annotations, all of them set in good conscience.
@Repository
@Transactional(readOnly = true)
public interface PromoPriceRepository extends JpaRepository<PromoPrice, Long> {
List<PromoPrice> findByRunningTrueAndEndsOnBefore( LocalDate cutoff );
}So does the service. The class carries readOnly = true because that is the project default for services and nobody thinks about it any more.
@Service
@Transactional(readOnly = true)
public class PriceMaintenance {
private static final Logger log = LoggerFactory.getLogger( PriceMaintenance.class );
private final PromoPriceRepository promoPrices;
public PriceMaintenance( PromoPriceRepository promoPrices ) {
this.promoPrices = promoPrices;
}
@Scheduled(cron = "0 15 3 * * *")
public void endExpiredPromotions() {
List<PromoPrice> expired = promoPrices.findByRunningTrueAndEndsOnBefore( LocalDate.now() );
for ( PromoPrice promo : expired ) {
promo.setRunning( false );
}
log.info( "{} expired promotions ended", expired.size() );
}
}At 3:15 the log says exactly what it is supposed to say:
2026-08-18 03:15:02 INFO PriceMaintenance : 412 expired promotions endedIn the database, 412 promotions are still running. No error, no stack trace, no rollback in the log. The application is convinced it has done its work, and the shop keeps selling at the promo price.
Prerequisites and versions
Everything in this article refers to Spring Boot 4 with Spring Data JPA 4 and Hibernate as the persistence provider. The mechanisms described are older than that: passing the read-only flag to the JDBC connection has existed since Spring Framework 4.1, passing it to the Hibernate session since Spring Framework 5.1. If you are on Spring Boot 3, you can take all of the following unchanged.
The database is PostgreSQL. That matters in one place, because PostgreSQL behaves differently from H2 in read-only transactions, and because this very difference means a bug does not show up in the test and does show up in production.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>Why a repository has a transaction at all
Before the hunt begins, it pays to look at what Spring Data already brings along without any help. The standard implementation behind every JPA repository is SimpleJpaRepository, and that class is itself annotated as transactional. At class level it carries @Transactional(readOnly = true), and the writing methods such as save or delete override that with a bare @Transactional.
Two things follow from that, and both are worth knowing.
A save works even when nobody in the service has opened a transaction. Spring Data opens one itself in that case, runs the statement and commits. That is convenient, and it is the reason many projects get by without transactions in the service layer for a long time without anything standing out.
The second point weighs more. The annotation covers only the methods SimpleJpaRepository implements itself. Derived query methods such as findByRunningTrueAndEndsOnBefore and your own @Query methods are handled by the Spring Data query machinery instead. They therefore inherit nothing.
That is exactly why you annotate the interface. @Transactional(readOnly = true) on the interface pulls your own query methods into the same arrangement the inherited methods already sit in. The benefit is real, the annotation is not decoration.
@Transactional(readOnly = true)
public interface PromoPriceRepository extends JpaRepository<PromoPrice, Long> {
List<PromoPrice> findByRunningTrueAndEndsOnBefore( LocalDate cutoff );
}Spring generally advises against putting @Transactional on interfaces, but that does not apply here. For Spring Data repositories this is the intended way, because the interface is the only place where you can annotate anything at all.
What readOnly actually switches on
The name suggests that readOnly = true is a guarantee, roughly in the spirit of final. It is not. It is a flag that is passed on in three places, and it does something different in each of them.
First it hits the Hibernate session. It is set to FlushMode.MANUAL. That removes the automatic flush before queries and at commit time. There is no longer any point at which Hibernate compares loaded entities against their original state.
Next comes the loaded state. Since Spring Framework 5.1 the flag is additionally passed to the Hibernate session as defaultReadOnly. Hibernate then discards an entity’s loaded state right away instead of keeping it for the entire lifetime of the persistence context. In normal operation that state is the basis of dirty checking and sits in memory as a second set of field values for every loaded entity.
That is the real gain, and it grows with the result set. On a list of five hits it is not measurable. On an export over six-figure data volumes it is the difference between a calm heap and a job that dies on memory.
Last of all the JDBC connection. Spring calls Connection.setReadOnly(true), provided the prepareConnection switch on HibernateJpaDialect is active. It has been active by default since Spring Framework 4.1. What the driver makes of it depends on the database, and that point deserves its own section further down.
The work readOnly saves is therefore clearly named: no comparison, no flush, no second set of field values in memory. What gets lost along the way is in the next section.
The resolution: why the UPDATE never came
Back to the job. The loop sets running to false, and it does so on entities that very much sit in the persistence context and are very much managed. The setter runs, the field in memory really does change. It is just that nobody is looking any more.
The automatic flush is switched off, so no comparison against the original state happens at commit time. And since the original state is no longer kept around, there would be nothing to compare it with. Hibernate produces no UPDATE because Hibernate knows nothing about the change.
No error is the worst property of all this. A rollback would appear in the log. An exception would wake up monitoring. Here simply nothing happens, and the job reports success because expired.size() really is 412.
Without readOnly
load snapshot kept in memory
setRunning(false) field changed in memory
commit compared against the snapshot, which produces an UPDATE
With readOnly
load snapshot discarded right away
setRunning(false) field changed in memory
commit no comparison, so no UPDATEA second path leads to the same result and is often confused with this one. If the service has no transaction at all, the repository opens its own for the query and closes it again when the method returns. The returned entities are detached afterwards. A setter on a detached entity is a pure memory operation, and here too no UPDATE follows. Two different causes, the same symptom, and both disappear as soon as the transaction boundary is set deliberately.
On PostgreSQL it fails more loudly
It is tempting to hope that the database catches the case from the previous section. It does not, and the reason is the same one: where Hibernate produces no UPDATE, nothing arrives at the database that it could reject. On PostgreSQL the nightly job stays exactly as quiet as anywhere else.
What PostgreSQL does catch is the other kind of failure, a write statement that really is sent while the transaction was opened as read-only. As described, Spring passes the read-only flag all the way to the JDBC connection, and the PostgreSQL driver acts on it.
The connection parameter in charge is readOnlyMode, with three possible values:
| Value | Behaviour on setReadOnly(true) |
|---|---|
ignore |
The flag has no consequences |
transaction |
With autocommit off, the driver sends BEGIN READ ONLY |
always |
Like transaction, except that with autocommit on the driver switches the whole session to read-only |
The default is transaction. Autocommit is off under a Spring transaction. The transaction therefore begins as an explicitly read-only transaction, and a statement that tries to write inside it fails at the database.
org.postgresql.util.PSQLException: ERROR: cannot execute UPDATE in a read-only transactionIn practice three routes lead there: a @Modifying statement without its own @Transactional, a native write query, and an explicit flush(). All three actually send something out. However unpleasant the exception looks at first, it is the friendlier outcome. An error that monitoring can see is clearly preferable to a silent non-change.
There is however a side effect teams regularly stumble over: pessimistic locking via @Lock also produces a SELECT ... FOR UPDATE, and PostgreSQL rejects that in a read-only transaction as well. A locking query must therefore not run under an inherited readOnly = true, even though it only reads in business terms.
Important for test planning: H2 behaves differently here and lets writes through in a transaction marked as read-only. A test against H2 therefore cannot find this bug. That is one of the reasons the test database should be the same one you run in production.
@Repository on the interface does nothing
A brief stop at the third annotation, because it is nearly everywhere and achieves nothing.
@Repository has two jobs. It makes a class discoverable for component scanning, and it registers it for translating persistence exceptions into Spring’s own DataAccessException hierarchy, handled by the PersistenceExceptionTranslationPostProcessor.
Both are already taken care of for a Spring Data repository before the annotation would ever come into play. The interface is found through the repository scan, not through component scanning, and exception translation is baked into the generated proxy. In this place the annotation is pure habit.
@Repository // no effect
@Transactional(readOnly = true) // effective
public interface PromoPriceRepository extends JpaRepository<PromoPrice, Long> {
}You still need it as soon as you write a data access class yourself, for instance an adapter working directly with the EntityManager or with JdbcClient. There it genuinely switches exception translation on.
@Repository
public class PromoPriceArchiveAdapter {
private final JdbcClient jdbcClient;
public PromoPriceArchiveAdapter( JdbcClient jdbcClient ) {
this.jdbcClient = jdbcClient;
}
public int archiveUntil( LocalDate cutoff ) {
return jdbcClient.sql( "insert into promo_price_archive select * from promo_price where ends_on < :cutoff" )
.param( "cutoff", cutoff )
.update();
}
}You do not have to delete the redundant annotation, it costs nothing. You just should not rely on it, because it is not the reason your repository works.
The second attempt: @Modifying
The obvious repair is to stop running the job over loaded entities. Loading four hundred rows one by one only to flip a flag in each of them is wasteful anyway. A single statement is enough.
That is what @Modifying is for. Without this annotation Spring Data tries to fetch the result of a @Query as a list of hits. With it the statement runs through executeUpdate() instead and returns the number of affected rows.
@Transactional(readOnly = true)
public interface PromoPriceRepository extends JpaRepository<PromoPrice, Long> {
List<PromoPrice> findByRunningTrueAndEndsOnBefore( LocalDate cutoff );
@Modifying
@Transactional
@Query("update PromoPrice p set p.running = false where p.running = true and p.endsOn < :cutoff")
int endExpired( @Param("cutoff") LocalDate cutoff );
}The second @Transactional on the method is neither an oversight nor double bookkeeping. The interface is set to readOnly = true, and an annotation on the method wins over the one on the class. Without it, the writing statement would run under the inherited read-only flag, with the consequences from the previous section.
The statement is worth a look in business terms too. The condition p.running = true is not redundant, even though it changes nothing functionally. It makes sure a second run touches no already ended rows, and that the return value reports the number actually changed instead of the total number of expired promotions.
That makes the job considerably shorter:
@Scheduled(cron = "0 15 3 * * *")
public void endExpiredPromotions() {
int affected = promoPrices.endExpired( LocalDate.now() );
log.info( "{} expired promotions ended", affected );
}This time the message matches the database. The job does what it should. The next stumbling block is elsewhere.
The stale persistence context
The job is to be extended. Before the run, one particular promotion is checked because it stood out in a support case, and after the run the log should say how that promotion fared.
@Transactional
public void maintenanceWithProbe( long probeId ) {
PromoPrice probe = promoPrices.findById( probeId ).orElseThrow();
int affected = promoPrices.endExpired( LocalDate.now() );
log.info( "{} promotions ended, probe still running: {}", affected, probe.isRunning() );
}The output contradicts itself:
2026-08-18 03:15:02 INFO PriceMaintenance : 412 promotions ended, probe still running: trueThe promotion has expired, it is among the 412 changed rows, and the database says running = false. In memory it still says true.
The reason is the nature of such a statement. A bulk update goes straight to the database and past the persistence context. Hibernate has no way of knowing which of the managed entities were affected, because to do so it would have to reproduce the WHERE condition in memory. So it leaves them untouched. Every further access to probe dutifully returns the state from before the update, and without a fresh query at that, because the entity sits in the persistence context.
The annotation has a switch for both directions, and both default to false.
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query("update PromoPrice p set p.running = false where p.running = true and p.endsOn < :cutoff")
int endExpired( @Param("cutoff") LocalDate cutoff );flushAutomatically = true flushes before the statement. That is necessary when an entity that the WHERE condition could hit was changed earlier in the same flow. Without the flush the database does not yet know about that change and therefore works on a state the application has already left behind.
clearAutomatically = true clears the persistence context after the statement. The next access to probe then triggers a fresh query and returns the correct value.
The price is readily overlooked. Clearing detaches all managed entities, not just the affected ones. Anything loaded and changed earlier in the same flow and not yet flushed is lost afterwards. That is why the two switches belong together, and why the middle of a longer business operation is a poor place for a bulk update. Such a statement runs most cleanly in its own short transaction in which nothing else happens.
What else a bulk update skips
The stale persistence context is the most conspicuous point, but not the only one. A statement that goes past Hibernate’s state management also goes past everything hanging off it. That is the logical consequence and precisely the price of the speed.
Lifecycle callbacks do not run. Neither @PreUpdate nor @PostUpdate, and for a deleting statement no @PreRemove either. If you maintain change stamps or audit trails through such callbacks, you lose them here silently. The same goes for Hibernate’s event listeners.
Cascades do not apply either. A delete from PromoPrice p where ... deletes promo prices but no dependent rows, even when the relation is declared with cascade = REMOVE. If the database has a foreign key, the statement fails. If it has none, orphans stay behind, and that often surfaces only months later.
The version column is left alone as well. A field with @Version stays unchanged unless you increment it in the statement yourself. A concurrent operation therefore still considers its copy current, and the optimistic lock does not trigger even though the row has changed.
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query("update PromoPrice p set p.running = false, p.version = p.version + 1 "
+ "where p.running = true and p.endsOn < :cutoff")
int endExpired( @Param("cutoff") LocalDate cutoff );And the second-level cache learns nothing about it. If a second-level cache is enabled for the entity, it keeps serving the old values after the bulk update, and beyond the transaction at that. Other users are then affected too.
None of this argues against bulk updates. It argues against sprinkling them in casually. A statement that changes four hundred rows in one go deserves a deliberate decision and a comment in the code explaining why the route through the entities is not taken here.
The turning point: three calls, three transactions
The job keeps growing, as jobs do. Once the promotions have ended, the affected catalog entries are to be flagged for re-indexing and an audit entry written. Three repository calls, cleanly separated, each tested on its own.
@Service
@Transactional(readOnly = true)
public class PriceMaintenance {
@Scheduled(cron = "0 15 3 * * *")
public void nightlyPriceMaintenance() {
LocalDate today = LocalDate.now();
int affected = promoPrices.endExpired( today );
catalogEntries.flagForReindex( today );
auditEntries.save( new AuditEntry( "price-maintenance", affected, today ) );
}
}The method itself has no writing transaction. It inherits readOnly = true from the class, and the three calls each bring their own transaction along, because they are annotated on the method or inherit from SimpleJpaRepository.
That makes every call atomic on its own and the method as a whole not at all.
Call 1 promoPrices.endExpired BEGIN ... COMMIT
Call 2 catalogEntries.flagForReindex BEGIN ... ERROR, ROLLBACK
Call 3 auditEntries.save never reached
Result promotions ended, search index still advertises them, no audit entryIf the second call fails, the promotions have already ended and committed. The search index keeps advertising a price the shop no longer offers, and the audit entry is missing. The data is in a state that must not exist in business terms, and no individual call did anything wrong.
This is the point where the real question surfaces. Every annotation on the repository describes how a single query runs. None of them describes what a unit of work is. Nor can it, because the repository knows nothing about the context it is called from.
The complete solution
The boundary belongs where the business logic sits. The repository keeps its annotations, they are still right, they are just not sufficient.
@Transactional(readOnly = true)
public interface PromoPriceRepository extends JpaRepository<PromoPrice, Long> {
List<PromoPrice> findByRunningTrueAndEndsOnBefore( LocalDate cutoff );
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Transactional
@Query("update PromoPrice p set p.running = false, p.version = p.version + 1 "
+ "where p.running = true and p.endsOn < :cutoff")
int endExpired( @Param("cutoff") LocalDate cutoff );
}The service sets the bracket. readOnly = true stays the default for the class because most methods read, and the writing method lifts it explicitly.
@Service
@Transactional(readOnly = true)
public class PriceMaintenance {
private static final Logger log = LoggerFactory.getLogger( PriceMaintenance.class );
private final PromoPriceRepository promoPrices;
private final CatalogRepository catalogEntries;
private final AuditRepository auditEntries;
public PriceMaintenance( PromoPriceRepository promoPrices,
CatalogRepository catalogEntries,
AuditRepository auditEntries ) {
this.promoPrices = promoPrices;
this.catalogEntries = catalogEntries;
this.auditEntries = auditEntries;
}
@Scheduled(cron = "0 15 3 * * *")
@Transactional
public void nightlyPriceMaintenance() {
LocalDate today = LocalDate.now();
int affected = promoPrices.endExpired( today );
catalogEntries.flagForReindex( today );
auditEntries.save( new AuditEntry( "price-maintenance", affected, today ) );
log.info( "{} expired promotions ended", affected );
}
}Now the service method opens the transaction, and the three repository calls run inside it, because the default propagation REQUIRED joins an existing transaction instead of opening a new one. If the second call fails, everything rolls back. The data knows only two states: before the maintenance or after it.
One detail is worth keeping in mind, because it is the most common follow-up question. Propagation REQUIRED joins an existing transaction including its read-only flag. A method with a plain @Transactional called from within a read-only transaction therefore does not lift the flag. It attaches itself to the existing transaction and still does not write. That is why @Transactional sits on the outermost method here and not somewhere further in.
The annotations at a glance
| Annotation | Where it belongs | What it does | What it cannot do |
|---|---|---|---|
@Repository |
Only on hand-written data access classes | Component scanning, translation of persistence exceptions | No effect whatsoever on a Spring Data interface |
@Transactional(readOnly = true) |
On the repository interface as the default | No automatic flush, no loaded state, read-only flag on the connection | Does not prevent writes, they merely vanish silently |
@Transactional on the method |
On every writing repository method | Lifts the interface’s read-only flag | Does not lift it when a read-only transaction is already running |
@Modifying |
On every @Query that writes |
Runs the statement through executeUpdate() |
Does not refresh the persistence context by itself |
@Modifying(clearAutomatically = true) |
When work continues after the statement | Clears the persistence context afterwards | Detaches all managed entities in the process, not just the affected ones |
@Modifying(flushAutomatically = true) |
When entities were changed beforehand | Flushes before the statement | Does not replace a deliberate order of operations |
Does readOnly belong on the repository or the service?
On both, and for different reasons.
On the repository it is a safety net. It ensures that a query accidentally called without a surrounding transaction still runs as a read and keeps its frugality. It also documents at the interface which methods read and which write, and that information is most useful exactly there.
On the service it is the business statement. A service method that assembles a report and queries seven repositories along the way should run as one read-only transaction, not as seven. That is the bigger lever, because it decides how many connections are pulled from the pool and how many queries see the same data state.
The order in practice: set the boundary in the service first, then annotate the repository. Doing it the other way round leaves you with a frugal repository in an application without transaction boundaries, and that is the combination from the scene at the very top.
When readOnly brings nothing
The honest part. readOnly = true is not a switch you put everywhere and that pays off everywhere.
With small result sets the gain is not measurable. Loading an entity by its key and reading one field saves you a comparison and a second set of field values. That is noise. The benefit appears with lists, reports and exports, in other words wherever many entities sit in the persistence context at the same time.
Projections and DTO queries barely benefit either, because no managed entities arise in the first place. There is no loaded state that could be saved. If you genuinely only want to read and the data volume is large, a projection usually beats readOnly on full entities.
With pessimistic locks it is actively harmful, as described above. A method with @Lock must not run under an inherited read-only flag.
The heaviest point is that readOnly replaces no guarantee. If you want to make sure a code path does not write, you need separate database users with different rights or a dedicated data source pointing at a read replica. An annotation does not deliver that. It is a frugality measure with a pleasant side effect, not access control.
FAQ
Do I need @Repository on my Spring Data interface?
No. The interface is found through the repository scan, and the generated proxy handles the translation of persistence exceptions. The annotation is only needed on hand-written data access classes.
Why do I need @Transactional in addition to @Modifying?
@Modifying only says how the statement is executed, it opens no transaction. If the interface carries readOnly = true, the method has to lift that explicitly, otherwise the writing statement runs under the read-only flag.
Why do I not see my change after a bulk update?
Because the statement goes past the persistence context and the entities sitting there stay unchanged. @Modifying(clearAutomatically = true) clears the context afterwards, along with every other managed entity though.
Does readOnly = true prevent writing?
Not at application level. Changes to entities vanish silently because no comparison takes place and therefore no statement is produced at all. As soon as a write statement really is sent, for instance from @Modifying without its own @Transactional, it fails at the database on PostgreSQL, because the driver opens the transaction as read-only.
Why does my test pass but production throws an error?
Most likely because the test runs against H2 and production against PostgreSQL. H2 lets writes through in a transaction marked as read-only, PostgreSQL does not. Test against the same database you operate.
Can I lift the read-only flag in an inner method?
Not with the default propagation REQUIRED. The inner method joins the running transaction including its flag. Either you set the boundary further out, or you deliberately open your own transaction with REQUIRES_NEW, which costs a second connection from the pool.
How many transactions run when my service sets no boundary?
As many as you make repository calls. Each brings its own. For a single query that is fine, for a unit of work made of several steps it is not.
Conclusion
The annotations on the repository interface answer a single question: how one individual query runs. @Transactional(readOnly = true) makes reads frugal and writes invisible. @Modifying turns a query into a statement and the persistence context into a source of bugs when work continues afterwards. @Repository does nothing at all in this place.
What none of them answers is where a unit of work begins and ends. The service sets that boundary, and when it is missing, the most careful annotation on the repository will not help.
The next step is small and pays off immediately: go through the service classes that make more than one repository call in a method and check whether a transaction is opened there. Where there is none, you have exactly the construction from the example above, just without the nightly job that makes it visible.
Sources
- Spring Data JPA Reference Documentation, Transactionality section: https://docs.spring.io/spring-data/jpa/reference/jpa/transactions.html
- Javadoc
org.springframework.data.jpa.repository.Modifying: https://docs.spring.io/spring-data/jpa/docs/current/api/org/springframework/data/jpa/repository/Modifying.html - Javadoc
org.springframework.orm.jpa.vendor.HibernateJpaDialect,prepareConnectionswitch: https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/orm/jpa/vendor/HibernateJpaDialect.html - PostgreSQL JDBC Driver, connection parameter
readOnlyMode: https://jdbc.postgresql.org/documentation/use/ - Spring Framework issue 21494, propagating the read-only flag to the Hibernate session: https://github.com/spring-projects/spring-framework/issues/21494
- Spring Data JPA issue 2141,
@Lockin read-only transactions: https://github.com/spring-projects/spring-data-jpa/issues/2141
All code examples are my own and were run on Spring Boot 4 with PostgreSQL.