CodeGym /Courses /Hibernate deep-dive /Managed, read-only, and query cache

Managed, read-only, and query cache

Hibernate deep-dive
Level 25, Lesson 4
Available

1. Introduction

If you approach “read optimization” as a choice of ice cream flavor, it is easy to fall into a trap: “I like cache, so let’s cache it.” But in Hibernate these three modes answer three different questions, and you need to compare them exactly that way — by the question you are solving. Otherwise you get a very characteristic result: complexity grows, speed does not improve, and a new legend appears: “Hibernate is slow because…”

No new magic appears here: the same familiar mechanisms simply live at different levels — inside the current persistence context, inside the current read transaction, and between transactions.

Let’s formulate these three questions in the most everyday language possible:

1) Do I need to change loaded objects and persist the changes within the current unit of work? If yes, you need the ordinary managed flow. This is not about speed, it is about correctness and the “right to write.”

2) I definitely will not change the data, but I still find it more convenient to read through an entity rather than through a projection. Can I make reading cheaper? This is where read-only appears. It does not make the query “without SQL”; it makes entities cheaper inside the current session.

3) The same query runs again and again in different transactions (for example, on every HTTP request), and the result is stable enough. Can I reuse the result between transactions? This is the domain of query cache (and/or second-level cache), that is, optimization at the “between application requests” level.

To make the difference stick, it helps to keep a small table in mind:

Approach What question it answers Boundary of effect lifetime Main cost/risk
Managed entity “Can I change and save?” current transaction / current persistence context snapshots + dirty checking + risk of accidental updates
Read-only entity “Can I read more cheaply within the transaction?” current transaction / current session you cannot expect changes to be saved, easy to get confused by “it changed in memory”
Cacheable query “Can I reuse the result between transactions?” between transactions (with L2/query cache) invalidation, freshness, and often dependence on second level

This table matters because it removes the main methodological illusion: read-only and query cache are not “faster managed entities,” but answers to different questions.

2. The read scenario from Commerce Lab

To make the comparison fair, we need the same read scenario. Let’s take something as realistic as possible from Commerce Persistence Lab: a list of active products for the back office. It is shown often, parameters usually repeat, and in most cases this is read-only access.

This is deliberately a boundary-case example. For shared/query cache, it is cleaner to start with Category and other read-mostly reference data, where changes are rare. But on the same Product list it is easier to compare managed, read-only, and cacheable approaches honestly, without jumping between use cases.

For such a list, it is convenient to keep a simple read model nearby right away. The projection here is not decorative: it shows that for lists, entity semantics are not always needed, and therefore the discussion about read-only and cache only makes sense after choosing a read model.

package com.example.commerce.catalog.dto;

import java.math.BigDecimal;

/**
 * Read-model for the product list: this is a DTO, not an entity.
 * It is not managed, does not participate in dirty checking, and has no entity lifecycle.
 */
public record ProductListRow(
        Long id,
        String sku,
        String name,
        BigDecimal amount
) {}

Here ProductListRow is just a “table row.” It is not managed, does not participate in dirty checking, and has no entity lifecycle. And this is an important part of the comparison: read-only and query cache should not make you forget the question “do I even need an entity?”

3. Option A: managed as the baseline

It is always useful to start the comparison with a baseline — not because it is the “best,” but because it is the most understandable and the most common. Managed loading is normal Hibernate behavior: you got entities, Hibernate created snapshots, it will do dirty checking, and if you change something, an UPDATE will be sent on flush/commit. This is not a bug; it is the contract. The bug begins when we use this contract in a read-only scenario and then are surprised by the cost.

Below is a simple baseline method that returns a list of Product as managed entities. It looks harmless, like a cat that did not knock anything over... until you turned away.

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

public class ProductCatalogQueries {

    public List<Product> loadManaged(EntityManager em) {
        // Baseline: return managed entities that will enter the persistence context
        // and participate in dirty checking on flush/commit.
        return em.createQuery("""
                select p from Product p
                where p.deleted = false
                order by p.name
                """, Product.class)
            .getResultList();
    }
}

What matters in this variant:

The entities will be managed. That means Hibernate will treat them as potentially mutable. For heavy lists, this adds a memory cost (snapshots) and CPU cost (dirty checking on flush). And yes, flush can happen not only at the end of the method, but for various reasons (we already covered that earlier in the course).

Now for the main “surprise” that makes managed loading dangerous in read-only scenarios. If somewhere in the code you accidentally (or “for just a minute”) change an entity, you will get a write to the database. And very often without save() — because dirty checking will do everything on its own.

import jakarta.persistence.EntityManager;
import org.springframework.transaction.annotation.Transactional;

@Transactional
public void readListAndAccidentallyMutate(EntityManager em) {
    // The entity is managed: a field change will be noticed on flush/commit via dirty checking.
    Product p = em.find(Product.class, 1L);

    // “Accidentally” change the state — that is enough for an UPDATE to go out on commit.
    p.setName(p.getName() + " "); // accidental update bait

    // commit -> UPDATE product SET name = ...
}

And this is where a typical production story appears: “we were just reading a list, why did we suddenly get an UPDATE?” The answer is unpleasantly simple: because a managed entity is a write-capable object, and Hibernate does exactly what it promised.

Note that the first-level cache also works in this scenario, but it solves a different problem. If you call em.find(Product.class, 1L) twice inside one transaction, you get the same Java object. But if you run a JPQL list query twice, SQL may still go to the database twice, even if Hibernate “stitches” the result into the same managed instances. This is the place where many people expect “magic” and get normal engineering reality instead.

4. Option B: read-only loading

Now we change not the shape of the query, but Hibernate’s attitude toward the already loaded entities. Read-only here is not needed to reduce the number of SELECT statements, but to lower the cost of the same entity-based read inside the current transaction: Hibernate does not have to keep these objects as candidates for a future UPDATE.

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

public class ProductCatalogQueries {

    public List<Product> loadReadOnly(EntityManager em) {
        return em.createQuery("""
                select p from Product p
                where p.deleted = false
                order by p.name
                """, Product.class)
            // Ask Hibernate to treat loaded entities as read-only within this query.
            .setHint("org.hibernate.readOnly", true)
            .getResultList();
    }
}

In this read case, the effect is local and very specific: the SQL for the list itself remains the same, but the unnecessary “readiness for writing” disappears. This is convenient when entity semantics are still needed, but writing is definitely not.

If someone does call a setter anyway, the field in the Java object will change, but read-only should not be treated as “managed, but faster and still saveable later.” For that scenario, either stay in the managed flow or move to a projection/DTO.

5. Option C: cacheable query

If read-only is an optimization within one unit of work, query cache lives between transactions. The question here is not dirty checking, but whether the same read case repeats across different service calls. That is why the cacheable variant below is intentionally built through DTO: it is easier to see reuse of a ready-made read result between transactions without tying the demo to reloading managed entities.

And one more important filter: the Product list here remains a teaching boundary-case example for continuity of comparison. The cleanest candidate for shared/query cache is usually Category and other read-mostly reference data, where repeats are frequent and invalidations are few.

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

public class ProductCatalogQueries {

    public List<ProductListRow> loadCacheableRows(EntityManager em) {
        return em.createQuery("""
                select new com.example.commerce.catalog.dto.ProductListRow(
                    p.id, p.sku, p.name, p.price.amount
                )
                from Product p
                where p.deleted = false
                order by p.name
                """, ProductListRow.class)
            // Allow Hibernate to cache the query result if query cache is enabled at all.
            .setHint("org.hibernate.cacheable", true)
            // Explicitly set a region so this becomes a separate cacheable read case.
            .setHint("org.hibernate.cacheRegion", "catalog.products.list")
            .getResultList();
    }
}

The .setHint("org.hibernate.cacheable", true) call by itself does not enable the mechanism as a whole. Query cache must be enabled globally, it needs a working shared-cache provider with a region factory, and for entity queries the practical gain usually also depends on a meaningful L2. Below is only a reminder-level snippet for the properties themselves, not the full setup:

spring:
  jpa:
    properties:
      # Enables the query cache mechanism itself.
      hibernate.cache.use_query_cache: true
      # For entity queries, a working L2 is usually also needed; these properties alone are not enough without a provider/region factory.
      hibernate.cache.use_second_level_cache: true

So cacheable query answers the question “can I reuse an already computed read result between transactions?” rather than “can I now stop thinking about SQL, invalidation, and data freshness?”

6. Comparison in SQL and statistics

Comparison in Hibernate almost always comes down to observability. If you compare “by feel,” the winner will be whatever you did last (because it is “fresh in your head”). We need a comparison that can be confirmed: by SQL logs and/or Hibernate statistics (if they are enabled by the stats profile).

A convenient comparison method looks like this: run the same use case twice, but in different transactions, because that is exactly where the difference between “intra-transaction” and “inter-transaction” mechanisms appears. For this, the laboratory project can conveniently use TransactionTemplate to explicitly create two units of work.

import org.springframework.stereotype.Component;
import org.springframework.transaction.support.TransactionTemplate;

@Component
public class CatalogReadComparisonRunner {

    private final TransactionTemplate tx;
    private final CatalogReadFacade facade;

    public CatalogReadComparisonRunner(TransactionTemplate tx, CatalogReadFacade facade) {
        this.tx = tx;
        this.facade = facade;
    }

    public void runTwice() {
        // Two different transactions: this is where the difference between read-only and query cache appears.
        tx.execute(s -> { facade.loadCatalogManaged(); return null; });
        tx.execute(s -> { facade.loadCatalogManaged(); return null; });
    }
}

Now, what expectations are reasonable.

In the managed variant, you will almost certainly see the SQL query executed both times. That is normal: the first-level cache does not cache a JPQL query result as a “list of rows”; it provides an identity map for entities. And you also know that if someone touches an entity during this read, you may get an UPDATE on commit.

In the read-only variant, you will also see the SQL query both times, because read-only is not about “do not execute SQL,” but about “do not make entities expensive to track changes for.” In return, part of the write overhead disappears, and the risk of accidental update drops significantly (but discipline is still required: do not count on writing).

In the cacheable query variant (with the mechanism enabled), the second run may not execute SQL for the query itself, because the result will come from query cache. And this is exactly why it is important to look not only at whether there is SQL, but also at what is being returned: DTO/projection or entity. If it is DTO, the chance of seeing “0 SQL” on the second transaction is higher. If it is entity, everything depends on the presence of a second-level entity cache.

To get a quick indicator, you can print a couple of statistics counters. In a real project you probably already have a lab-support utility, but even a raw print from Statistics is useful.

import org.hibernate.SessionFactory;
import org.hibernate.stat.Statistics;

public class StatsView {

    public void print(SessionFactory sf) {
        // Important: statistics must be enabled in configuration, otherwise the numbers will be zero/useless.
        Statistics st = sf.getStatistics();

        // How many times queries were actually executed (in Hibernate statistics terms).
        System.out.println("Query executions = " + st.getQueryExecutionCount()); // for example: 2

        // How many entities were loaded (for DTO/projection often 0).
        System.out.println("Entity load count = " + st.getEntityLoadCount());   // for example: 0 for DTO
    }
}

Yes, these are rough indicators, but they are excellent for disciplining your thinking: you stop arguing with words and start arguing with numbers. Hibernate usually wins exactly when you talk to it in numbers, not emotions.

For reinforcement, it is useful to summarise the comparison in a “what exactly are we saving” table:

Variant SQL on the query Snapshots / dirty checking Inter-transaction reuse
Managed entity usually yes, every time yes no
Read-only entity usually yes, every time less / may be disabled for these entities no
Cacheable query (DTO) 1st time yes, 2nd time may not run not applicable (DTO) yes

Mini decision helper

I want to end the lecture not with the slogan “use read-only and cache,” but with a small decision helper you can apply in code review. It is very simple: first choose the read shape, then the session mode, then caching. If you mix up the order, you start treating symptoms instead of causes.

Below is a scheme that usually saves you from typical “cache-first thinking”:

flowchart TD
    A["Do you need to write within the use case?"] -->|Yes| M["Managed entity (normal mode)"]
    A -->|No| B["Do you need entity semantics (lazy loading, navigation, domain methods)?"]
    B -->|Yes| R["Read-only entity (hint or session default)"]
    B -->|No| P["Projection/DTO as the read model"]
    P --> C["Does the query repeat often with the same parameters?"]
    C -->|Yes| Q["Cacheable query + region (with query cache enabled)"]
    C -->|No| N["Regular query without cache"]

The important part here is that query cache comes after the decision “entity or projection.” This is not accidental; it is discipline: if you load entity in a list “just because it is easier,” and then try to cache it, you often cache the wrong thing and pay for invalidation where it would have been simpler to just read fewer columns.

And read-only sits exactly where an entity is still needed, but writing is not. This is the case where you can “ask Hibernate not to strain,” and it, as a proper ORM, really will not.

7. Typical mistakes when working with cache

Mistake No. 1: enabling cacheable query as a cure for bad SQL or N+1.
This is a very common story: a query performs 50 SQL operations because of a fetching mistake, and instead of fixing fetching, the developer tries to cache everything. As a result, things get more complicated, and sometimes even worse: you cache an inefficient query, pay for invalidation, and still suffer on cache misses. The correct sequence is the opposite: first proper read shape, then local optimizations, then caching.

Mistake No. 2: using read-only mode and then being surprised that data was not saved.
Read-only is not “managed, but faster”; it is a mode in which Hibernate is not required to persist changes. If in the middle of the method you decide to “slightly fix the name” and expect an UPDATE, you broke the contract yourself. In such cases, separation usually helps: one method reads (read-only), another modifies (managed).

Mistake No. 3: thinking that @Transactional(readOnly = true) is an ironclad ban on writing.
In Spring this is first and foremost a hint. It can influence flush mode and provider behavior, but it is not a “guard with a baton” that will knock every UPDATE out of your hands. Real enforcement comes from architecture (separating use cases) and code discipline, not from one annotation.

Mistake No. 4: expecting query cache to help without parameter repeatability.
Query cache stores the result of a query with specific parameters. If every query is different (different filters, dynamic order by, different pages), the cache will almost always miss, and you will pay for maintaining it. This is not a bug; it is the wrong candidate.

Mistake No. 5: caching an entity query and forgetting that without a second-level cache of entities you may still see SQL.
This is the most insidious trap: “I enabled query cache, why are there still queries?” Because query cache often stores a list of identifiers, and then Hibernate still has to obtain the entity state. If the entities are not cached at the second level, it will go to the database. That is why for demos and for many read cases, it is more honest to cache DTO/projection, and keep entity caching as a separate, careful decision.

1
Task
Hibernate deep-dive, level 25, lesson 4
Locked
Managed vs. read-only in two rollback transactions
Managed vs. read-only in two rollback transactions
1
Task
Hibernate deep-dive, level 25, lesson 4
Locked
Three read strategies on one Category list
Three read strategies on one Category list
1
Survey/quiz
Hibernate cache, level 25, lesson 4
Unavailable
Hibernate cache
First- and second-level cache
Comments
TO VIEW ALL COMMENTS OR TO MAKE A COMMENT,
GO TO FULL VERSION