CodeGym /Kursy /Hibernate deep-dive /Read-only: request, session, transaction

Read-only: request, session, transaction

Hibernate deep-dive
Poziom 25, Lekcja 1
Dostępny

1. Managed reads can be expensive

If you think about Hibernate “by feel,” everything seems simple: you run a select, get Java objects, and move on. But Hibernate is not a library for “getting data”; it is a unit of work engine. Even when you do not change anything, it prepares for the possibility that you may change your mind, go ahead, and modify something after all. And that readiness also costs money: CPU, memory, and sometimes unexpected side effects.

When you load an entity as a normal managed object, Hibernate puts it into the persistence context and, as a rule, creates a snapshot — in simpler terms, it “photographs” the state at entry. At the end of the transaction (or earlier, if flush() is triggered), the engine compares the entity’s current fields with the snapshot and decides whether an UPDATE is needed. In a small CRUD service, this does not look like a problem. But as soon as you get a scenario like “read 5,000 products for export/report/reindexing,” Hibernate honestly keeps 5,000 managed objects around and potentially prepares to check them.

It is important not to mix things up: read-only mode is not about reducing the number of SELECTs. It does not fix N+1, and it does not make a query “better.” It is more about making Hibernate do less extra work on reads related to “what if you update something?”

Below is a simple diagram showing why ordinary managed loading is more expensive than it seems:

%% (8) Wrap labels with ":" in quotes so Mermaid does not stumble over special characters
flowchart TD
    A[SELECT from DB] --> B[Entity in persistence context]
    B --> C[Snapshot for dirty checking]
    C --> D[You do something with the object]
    D --> E["Flush: compare snapshot and current state"]
    E --> F[UPDATE if there are changes]

Read-only is an attempt to tell Hibernate honestly: “Buddy, today we are in a museum. We can look, but not touch.”

2. Read-only in Hibernate is a mode

The phrase “read-only” confuses many people because it sounds like “you cannot change the object.” But in Java you can always call a setter, assign a field, or replace a reference — the JVM will not ask Hibernate for permission. Read-only in ORM is not handcuffs on your hands; it is a change in how the ORM treats loaded data.

At the Hibernate level, read-only most often means that the entity will be excluded from dirty checking. In other words, you can change the object in memory, but Hibernate is not required to notice it and is not required to generate an UPDATE. As a result, you get two effects. First, overhead from snapshots and change checks decreases. Second, you protect yourself from accidental “oops, the setter was called” moments and unexpected UPDATEs at the end of the transaction — but only if read-only is enabled correctly and at the right level.

Here is a similar, but more “read-only” diagram:

%% (8) Wrap labels with ":" in quotes so Mermaid does not stumble over special characters
flowchart TD
    A[SELECT from DB] --> B[Entity loaded as read-only]
    B --> C[Changes in the Java object are still possible]
    C --> D["Flush: dirty checking skips the entity"]
    D --> E[No UPDATE is generated]

And this is where the interesting part begins: in Hibernate and around it there are several “layers” where read-only can be enabled. They look similar by name, but differ in effect and scope. So from here on we will not say “read-only in general,” but rather “read-only at the request / session / transaction level.”

3. Three read-only levels: request, session, transaction

When a developer first hears “read-only,” they often imagine one magical checkbox that says “speed up reads.” In reality, these are three different tools, and each answers its own question: do we want to mark the result of a specific query, do we want to switch an entire use case into read mode, or do we want to declare at the Spring transaction level that a method is for reading only?

To avoid keeping all of this in your head as a mess, it helps to lock in the comparison once. This is not “the truth across all versions of the world,” but a practical map for our course and our project:

Level How it is enabled Scope What it gives you Where it makes sense Main risk
Query-level read-only hint "org.hibernate.readOnly", or Query#setReadOnly(true) Only the results of a specific query Less dirty checking for these entities One heavy query in the middle of an ordinary session You may expect “a ban on changes,” but this is not a ban, it is ignorance
Session-level default read-only Session#setDefaultReadOnly(true) All subsequent loads in this session (until turned off) Convenient for a “clean read use case” Report / export / search where there is definitely no write If you forget to switch it back, writes may “suddenly stop persisting”
Transaction-level read-only @Transactional(readOnly = true) Transaction boundary (unit of work) A hint to the infrastructure, often reduces flush activity Service methods that are “read only” Assuming it is an “ironclad ban on writes” (it is not)

Next we will look at each level separately, and most importantly not only “how to enable it,” but also “what exactly changes in Hibernate behavior” and “where it is easy to make a mistake.”

4. Query-level read-only: mode for a query

The most understandable (and the safest for your sanity) option is to make read-only exactly where it is needed: for one specific query. This approach is like a scalpel: you do not “switch the whole operation to read-only,” but tell Hibernate: “I definitely do not plan to change this result, so do not waste extra effort on it.”

Read-only hint via JPA setHint(...)

Hibernate supports the Hibernate-specific hint "org.hibernate.readOnly". From the JPA point of view this is just a string hint, but from Hibernate’s point of view it is a signal to mark loaded entities as read-only.

An example in our domain (Product) might look like this:

import jakarta.persistence.EntityManager;
import java.util.List;

// Important: the hint affects Hibernate behavior (dirty checking), not "magically speeding up SELECT."
List<Product> products = entityManager.createQuery(
        "select p from Product p where p.deleted = false order by p.name",
        Product.class
    )
    // Hibernate-specific hint: mark loaded entities as read-only.
    .setHint("org.hibernate.readOnly", true)
    // Still an ordinary query: the result will enter the persistence context (identity map).
    .getResultList();

Here it is important not to overestimate the magic: the query will still run a SELECT against the database, the entities will still end up in the persistence context (that is, they will be “managed” in the identity map sense), but Hibernate is not required to include them in the dirty checking mechanism the way it would for ordinary managed entities.

readOnly does not mean “the Java object cannot be changed”

The second point that trips up beginners: you can change the object in memory even if it is read-only. Hibernate just probably will not write that change to the database.

A clear example, with a small comment about what will happen:

import jakarta.persistence.EntityManager;

// The entity is loaded through a query with a read-only hint.
Product product = entityManager.createQuery(
        "select p from Product p where p.id = :id",
        Product.class
    )
    // Query parameterization: ordinary practice, read-only has nothing to do with this.
    .setParameter("id", 1L)
    // Mark only the result of this query as read-only.
    .setHint("org.hibernate.readOnly", true)
    .getSingleResult();

// You can change the Java object — the JVM does not ask Hibernate.
// UPDATE usually will not happen because the entity is excluded from dirty checking.
product.setName("Changed only in memory");

If you commit the transaction after this, you may very well see that no UPDATE product ... appeared. For “clean reading,” that is a plus. For “I thought I saved it,” that is a great way to do a little reality therapy.

Checking the read-only status

Sometimes it is useful not to guess from the SQL log, but to ask Hibernate directly: “Is this entity read-only or not?” For this, you can temporarily use Session#isReadOnly(...) during a lab analysis.

import jakarta.persistence.EntityManager;
import org.hibernate.Session;

// In Spring/JPA we usually start with EntityManager, but Hibernate Session is available via unwrap.
Session session = entityManager.unwrap(Session.class);

Product p = entityManager.createQuery(
        "select p from Product p where p.id = :id", Product.class)
    .setParameter("id", 1L)
    .setHint("org.hibernate.readOnly", true)
    .getSingleResult();

// Direct Hibernate check: this way we do not guess from logs and "feel."
System.out.println(session.isReadOnly(p)); // true

Note the subtlety related to the first lecture of the day. If an entity with this id was already loaded into the persistence context earlier as a normal managed object, Hibernate will return the same instance (identity map), and “reloading it as read-only in another query” may no longer work the way you expect. Inside one persistence context, you cannot have two different versions of “one row — two entities with different modes.” This is a direct consequence of the identity map.

Read-only and the size of the persistence context

Read-only reduces the cost of dirty checking, but it does not turn loading 10,000 entities into “nothing.” They will still live in the first-level cache, consume memory, and participate in association navigation. If your goal is “read a lot and not keep it in the context,” read-only is not the only tool (and may not even be the main one). But within this lecture, let us fix the idea: query-level read-only is useful when the SQL is already fine and you want to remove the extra “readiness to write” for a specific result.

5. Session-level read-only: read-only by default

There is another class of tasks: you know for sure that the current session/transaction is pure reading. For example, you are building a catalog export, generating a report, or collecting data for analytics. In those cases you do not want to annotate each query with hints — you want to enable read-only “by default” for all loads in this session.

Session#setDefaultReadOnly(true) and try/finally

At the Session level, Hibernate lets you set a “default” read-only mode for all entities loaded afterward. In a Spring application we usually start with EntityManager, but we can reach Session through unwrap().

The most important practice here is boring, but lifesaving: if you switched defaultReadOnly, be sure to switch it back. Otherwise, the next piece of code in the same session may suddenly stop persisting changes, and you will stare at it like a disappearing coin trick.

import jakarta.persistence.EntityManager;
import org.hibernate.Session;

// Get the Hibernate Session so we can manage default read-only mode at the session level.
Session session = entityManager.unwrap(Session.class);

// Remember the previous value so we can restore it reliably.
boolean prev = session.isDefaultReadOnly();

// Turn on read-only by default for all subsequent loads in this session.
session.setDefaultReadOnly(true);
try {
    // All entities loaded by this query will be read-only (if they were not already in the context).
    return session.createQuery("from Product p order by p.id", Product.class)
        .getResultList();
} finally {
    // Critical: restore the mode, otherwise the next write logic may "silently" stop working.
    session.setDefaultReadOnly(prev);
}

Yes, this looks like “extra dancing.” But these are the dances that later save you hours of “why are the data not updating even though the setter was called?”

Trying to change data in a read-only session

If entities are loaded as read-only, you can change them in memory — and that change will most likely not go to the database. The problem is not that Hibernate is “malicious.” The problem is that you yourself said: “I am reading.” In other words, read-only at the session level is no longer a local optimization; it is a contract: this piece of the unit of work should not have hidden writes.

In practice, this helps not only performance but also discipline. When you consciously separate “read” from “write,” you reduce the chance of accidental UPDATEs in unexpected places. True, it creates a new chance: “I thought I was writing, but this was a read-only session.” So again: try/finally, and preferably use this only in places where the scenario is truly a clean read.

6. Transaction-level read-only in Spring

When we live in Spring, our hand naturally reaches for the most convenient and “official” method: put @Transactional(readOnly = true) on it and assume the world got better. You can and should do that — but it is important to understand that, first and foremost, this is a contract and a hint, not an “ironclad ban on writes.” Otherwise you will get very strange bugs: sometimes data updated, sometimes not, sometimes updated only because someone called saveAndFlush().

Mental model: a read-only transaction is a promise

From an architecture point of view, @Transactional(readOnly = true) makes your service method more honest. It says: “This use case only reads.” That immediately helps you read code, do code review, and find places where someone tried to mix reading and writing.

In some Spring + Hibernate configurations, a read-only transaction can also reduce flush activity (for example, by switching flush mode into a more “lazy” mode for the current unit of work). But for the course, the more important thing is this: even if the infrastructure optimized nothing, the contract itself is still useful because you start designing read and write boundaries consciously.

Example of a read-only use case in Commerce Persistence Lab

Suppose we have a “catalog” read use case: return a list of active products for the admin panel (or for export). In this method we definitely do not change anything.

import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;

@Transactional(readOnly = true) // Contract: the method must not contain write logic.
public List<Product> loadActiveProductsForBackoffice() {
    // JPQL as a text block is easier to read and edit, especially when the query becomes multiline.
    // This is still an ordinary SELECT: readOnly=true on the transaction does not "magically" change SQL.
    return entityManager.createQuery(
            """
            select p
            from Product p
            where p.deleted = false
            order by p.name
            """,
            Product.class
        )
        .getResultList();
}

This is the minimal, “correct” level: a contract at the service level. If later you find that the load is really high and dirty checking is noticeable, you can add a query-level hint for a targeted boost. But it is better to start with the boundary: read-only at the transaction level primarily enforces discipline.

@Transactional(readOnly = true) is not a “no writes” rule

It is very tempting to think that Spring will now not allow you to call save(). But the annotation does not turn your code into a sacred cow: you can still call a repository, you can still execute UPDATE/INSERT, and a lot depends on how the stack is configured, which methods you call, and where flush happens.

So the rule is simple and grown-up: if a method is marked read-only, it must not contain write logic. Not “probably won’t,” not “usually does not write,” but outright must not. If you suddenly find a reason to write, then it is a different use case, and it needs a separate transactional boundary.

Combining levels

Sometimes the most effective option is to combine the transaction contract with query-level read-only for a heavy query. The key is not to turn this into a religion of “set readOnly everywhere.” Use it where it really makes sense: large lists, reporting reads, exports, scanning operations.

import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;

@Transactional(readOnly = true) // Contract: read-only use case.
public List<Product> loadProductsReadOnly() {
    return entityManager.createQuery("select p from Product p", Product.class)
        // Explicitly mark the query result as read-only so Hibernate does not waste time on dirty checking.
        .setHint("org.hibernate.readOnly", true)
        .getResultList();
}

The point of the combination is this: @Transactional(readOnly = true) fixes the use-case contract, and the hint makes the optimization and entity behavior more explicit for this specific query.

7. Choosing the read-only level

After all of that, it is easy to draw the wrong conclusion: “Aha! So everything should be read-only.” No. Read-only is a good tool when you understand what exactly you are optimizing and what side effects you are accepting. In our course we already discussed that for lists, projections are often better, because they do not bring managed-entity semantics into a place where they are not needed at all. Read-only is a step inside the entity-loading world, while a projection is a step to “leave the entity world where it is not needed.”

If you are making a simple query that returns 20 rows and immediately mapping them to DTOs, in most cases you will not see any benefit from read-only. On the other hand, you may get an unexpected effect: someone changed the entity in memory, but it was not saved, and this feels like “Hibernate is broken.”

A useful engineering approach, without fanaticism, is this: first you fix the query shape, remove N+1, choose a projection where it fits, and only then, if reading is still heavy, you look at read-only as a local reduction of dirty checking cost. In other words, read-only is not first aid; it is fine-tuning of an already normal solution.

Checking that read-only worked

In Hibernate there are two quick ways not to fool yourself. The first is to look at the SQL log and confirm that no unexpected UPDATE is actually happening. The second is to check the entity’s read-only status through the Hibernate API. Both methods are good because they turn the discussion from “seems faster” into “here is the observable behavior.”

We already saw the example with checking status through Session#isReadOnly(...), but here the principle itself matters: if you are doing an optimization, you must be able to explain it in terms of observable effect. You do not have to measure nanoseconds. It is enough to understand that the entity is excluded from dirty checking, and this can be confirmed by something more than faith in an annotation.

Another useful check is to remember the persistence context boundary. Read-only does not make entities into ghosts. They can still live in the first-level cache, which means that with long transactions and large result sets you can still hit memory limits. So read-only works well together with the discipline of “do not keep giant persistence contexts where they are not needed,” but by itself it does not solve that problem.

8. Common mistakes when working with read-only

Mistake #1: treating read-only as a ban on calling setters.
At the Java level you can change the object however you want, because it is just an ordinary object. Read-only is an agreement with Hibernate about whether it will track changes. So “I changed the field” does not mean “I saved the change.” This is especially unpleasant when the code looks logical, but the database “for some reason” did not update.

Mistake #2: treating @Transactional(readOnly = true) as an ironclad ban on writes.
A read-only transaction in Spring is primarily a contract and a hint. It improves discipline and sometimes optimizes flush behavior, but it does not turn your database into a monastery where UPDATE cannot be brought in. If there is suddenly a save() inside such a method, that is not “bold optimization” but an architectural boundary error in the use case.

Mistake #3: using read-only instead of a proper read model (projection).
If your use case is an admin list, a table, an export, or a report, very often you do not need an entity at all. A read-only entity is still an entity in the persistence context, with its associations, proxies, and lifecycle. Projections and DTO queries are often simpler, cheaper, and more predictable than “entity, but read-only.”

Mistake #4: mixing read-only loading and writing in the same unit of work.
Sometimes this seems convenient: “I’ll read this read-only thing now, then tweak it a little and save it.” In practice this becomes a hard-to-debug story where some entities are read-only, some are not, and the identity map also hands you the same object from the first-level cache. If the scenario writes, make it a write scenario and do not complicate it with read-only magic.

Mistake #5: enabling session-level default read-only and forgetting to turn it off.
This is a classic. The code works while you are generating a report. Then in the same thread (or the same session) you try to change an entity — and the changes “do not persist.” Suspicion starts falling on Hibernate, Spring, transactions, moon phases, and the neighbor’s Wi‑Fi. But there is one reason: you did not restore setDefaultReadOnly(false) (or the previous value) in finally.

1
Zadanie
Hibernate deep-dive, poziom 25, lekcja 1
Niedostępne
Query-level read-only dla jednego produktu
Query-level read-only dla jednego produktu
Komentarze
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION