Hibernate @Immutable: Read-Only Entities and Collections

Hibernate’s @Immutable annotation stops UPDATE statements for an entity, blocks changes to a collection and, since Hibernate 7, fails HQL update queries by default. We compare it with updatable = false, read-only queries, @Subselect and records.

logo

Hibernate’s @Immutable annotation tells Hibernate that the rows of an entity are never updated after they are inserted, so Hibernate stops tracking changes to those objects and never sends an UPDATE for them. The annotation comes from the org.hibernate.annotations package, and Jakarta Persistence has no such annotation.

We use @Immutable for data that we insert once and only read afterwards, for example the entries of an accounting ledger or the price history of an online shop.

The following example maps an immutable ledger entry and shows which operations reach the database.

@Entity
@Immutable
public class LedgerEntry {

  @Id
  @GeneratedValue
  private Long id;

  private String description;
  private BigDecimal amount;
  private LocalDateTime postedAt;
}
em.persist(coffee);                     // insert into LedgerEntry (amount,description,...) values (...)
coffee.setDescription("Tea");           // no SQL at commit, the row keeps "Coffee beans"
em.remove(coffee);                      // delete from LedgerEntry where id=?
Query update = em.createQuery("update LedgerEntry set description = 'Tea' where id = :id");
                                        // HibernateException: The query attempts to update an immutable entity
journal.post(milk);                     // commit fails: Immutable collection was modified

Notice that Hibernate ignores the changed description without any warning, whereas the update query and the change to an @Immutable collection fail with an exception. The update query fails by default since Hibernate 7.

Next, we look at how @Immutable works during a flush and build a household ledger that tries every operation. After that, we compare @Immutable with read-only queries, @Subselect and record projections.

1. How Does @Immutable Work?

Hibernate finds changed entities with dirty checking. When it loads a normal entity, it stores a copy of the loaded values, called the snapshot, in the persistence context. At flush time (by default at commit), Hibernate compares every managed entity with its snapshot and sends an UPDATE for each difference.

An @Immutable entity is loaded in the READ_ONLY state and gets no snapshot, so the flush skips it. We can see the missing snapshot with session.isReadOnly() and the entry that the persistence context keeps for each object (<entries> stands for the collection).

isReadOnly(coffee) = true
LedgerEntry: status = READ_ONLY, snapshot = null
Journal:     status = MANAGED, snapshot = [<entries>, null, Household, 2026-01-01, Lokesh]

The mutable Journal carries one snapshot value per attribute. The immutable LedgerEntry carries none, which saves memory and skips the comparison at flush time.

Flush comparison: for the mutable Journal, Hibernate keeps a snapshot, compares it with the current values at flush and sends update Journal set memo=?,name=? where id=?; for the @Immutable LedgerEntry, the entry is READ_ONLY with no snapshot, the flush skips it and no SQL is sent
At flush, Hibernate compares a mutable entity with its snapshot. An @Immutable entity has no snapshot, so Hibernate skips it.

The @Immutable annotation blocks only updates. Inserts and deletes still work, and Hibernate does not check native SQL at all.

Operation on LedgerEntryWhat Hibernate 7.4 does
em.persist(entry)insert into LedgerEntry …
Change a field of a loaded entryNo SQL, no warning, the row stays as it was
em.merge(changedCopy)No SQL, the row stays as it was
em.remove(entry)delete from LedgerEntry where id=?
HQL/JPQL update LedgerEntry …HibernateException by default
HQL/JPQL delete from LedgerEntry …Runs
Native SQL update LedgerEntry …Runs
Add or remove an element of an @Immutable collectionHibernateException at commit

2. @Immutable Example

Our example is a household accounting ledger with Hibernate 7.4, Java 25 and H2. A journal (“Household”) holds ledger entries such as “Coffee beans” for 40.00 and “Pancakes” for 12.50. A posted ledger entry must never change, so a correction is a new entry, not an edit of the old one.

2.1. The Ledger Domain Model

The Journal entity is a normal, mutable entity, so we can rename it. The LedgerEntry entity is immutable, and so is the journal’s list of entries. Two journal attributes, openedOn and owner, are fixed after the insert as well, in two different ways.

Entity diagram: mutable Journal with name, @Immutable openedOn, owner with @Column(updatable = false) and an @Immutable entries collection; @Immutable LedgerEntry with description, amount, postedAt and a journal_id foreign key to the Journal table
LedgerEntry is immutable as a whole. Journal stays mutable, but its entry list and two of its columns are not.
private String name;

@Immutable
private LocalDate openedOn;

@Column(updatable = false)
private String owner;

@OneToMany(mappedBy = "journal", cascade = CascadeType.PERSIST)
@Immutable
private List<LedgerEntry> entries = new ArrayList<>();

public void post(LedgerEntry entry) {
  entries.add(entry);
  entry.setJournal(this);
}

The entry holds the foreign key through a @ManyToOne association, loaded LAZY.

@ManyToOne(fetch = FetchType.LAZY)
private Journal journal;

Creating the journal works as usual. The new entries are inserted through the cascade on entries.

Journal journal = new Journal("Household", "Lokesh", LocalDate.of(2026, 1, 1));
journal.post(new LedgerEntry("Coffee beans", "40.00", LocalDateTime.of(2026, 10, 1, 9, 0)));
journal.post(new LedgerEntry("Pancakes", "12.50", LocalDateTime.of(2026, 10, 2, 8, 30)));
em.persist(journal);
// insert into Journal (memo,name,openedOn,owner,id) values (?,?,?,?,?)
// insert into LedgerEntry (amount,description,journal_id,postedAt,id) values (?,?,?,?,?)
// insert into LedgerEntry (amount,description,journal_id,postedAt,id) values (?,?,?,?,?)

The @Immutable collection can be filled when the parent is new. The check applies when we change the collection of a journal loaded from the database (section 2.5).

2.2. Changing an Immutable Entity

We load an entry with em.find(), change its description and commit. Hibernate sends only the SELECT.

emf.runInTransaction(em -> {
  LedgerEntry coffee = em.find(LedgerEntry.class, coffeeId);
  coffee.setDescription("Tea");
});
// select le1_0.id,le1_0.amount,le1_0.description,... from LedgerEntry le1_0 where le1_0.id=?
// no update at commit
// description in database = Coffee beans

Hibernate does not throw an exception or log a warning for the ignored change. The setter still works in memory, so the Java object holds “Tea” while the row keeps “Coffee beans”. A detached copy passed to em.merge() behaves the same way.

detached.setDescription("Green tea");
LedgerEntry merged = emf.callInTransaction(em -> em.merge(detached));
// merged = Green tea 40.00
// no update, description in database = Coffee beans

2.3. Deleting an Immutable Entity

The @Immutable annotation does not block deletes, and em.remove() deletes the row as for any other entity.

emf.runInTransaction(em -> em.remove(em.find(LedgerEntry.class, pancakesId)));
// select le1_0.id,le1_0.amount,... from LedgerEntry le1_0 where le1_0.id=?
// delete from LedgerEntry where id=?

If ledger rows must never be deleted either, we remove the delete code paths or revoke the DELETE privilege on the table in the database.

2.4. Bulk HQL and JPQL Updates

A bulk update is an HQL or JPQL update statement that changes rows in the database without loading them. Since Hibernate 7.0, an update query on an immutable entity throws a HibernateException by default. The query fails when it is created, before any SQL is sent.

int rows = em.createQuery("update LedgerEntry set description = 'Tea' where id = :id")
    .setParameter("id", coffeeId)
    .executeUpdate();         // HibernateException
org.hibernate.query.sqm.InterpretationException: Error interpreting query [The query attempts to update an immutable entity: [LedgerEntry] (set 'hibernate.query.immutable_entity_update_query_handling_mode' to suppress)] [update LedgerEntry set description = 'Tea' where id = :id]
Caused by: org.hibernate.HibernateException: The query attempts to update an immutable entity: [LedgerEntry] (set 'hibernate.query.immutable_entity_update_query_handling_mode' to suppress)

The setting named in the message accepts three values.

immutable_entity_update_query_handling_modeResult of update LedgerEntry set amount = amount 2*
exception (default since 7.0)HibernateException, no SQL sent
warning (default before 7.0)Runs (rows = 2) and logs HHH000487
allowRuns (rows = 2), nothing logged

We set it when we build the EntityManagerFactory, here with HibernatePersistenceConfiguration.

new HibernatePersistenceConfiguration("immutable")
    .managedClasses(Journal.class, LedgerEntry.class, JournalBalance.class)
    .property("hibernate.query.immutable_entity_update_query_handling_mode", "allow")
    ...
// update LedgerEntry le1_0 set amount=(le1_0.amount*2)
WARN org.hibernate.orm.core - HHH000487: The query [update LedgerEntry set amount = amount * 2] updates an immutable entity: [LedgerEntry]

The enum behind the setting, ImmutableEntityUpdateQueryHandlingMode, is deprecated for removal since 7.0 and will be replaced by a boolean switch. Two kinds of statements are not checked at all in 7.4.11.

int deleted = em.createQuery("delete from LedgerEntry where description = 'Milk'").executeUpdate();
// delete from LedgerEntry le1_0 where le1_0.description='Milk'      rows = 1

int updated = em.createNativeQuery("update LedgerEntry set description = 'Tea' where id = ?1")
    .setParameter(1, coffeeId)
    .executeUpdate();
// update LedgerEntry set description = 'Tea' where id = ?            rows = 1

The Hibernate 7 migration guide says bulk deletes fail as well, but in 7.4.11 the delete query runs in the default mode. A native update is plain SQL, so Hibernate cannot know which entity it touches.

2.5. Adding an Entry to an Immutable Collection

The journal’s entries list is immutable, so adding or removing an element fails at commit and the transaction rolls back. No row is inserted or deleted.

emf.runInTransaction(em -> em.find(Journal.class, journalId)
    .post(new LedgerEntry("Milk", "3.20", LocalDateTime.of(2026, 10, 3, 7, 0))));

emf.runInTransaction(em -> em.find(Journal.class, journalId).getEntries().remove(0));
jakarta.persistence.RollbackException: Error while committing the transaction [Immutable collection was modified: [com.howtodoinjava.hibernate.immutable.Journal.entries with owner id '1']]
Caused by: org.hibernate.HibernateException: Immutable collection was modified: [com.howtodoinjava.hibernate.immutable.Journal.entries with owner id '1']

The collection is the inverse side of a @OneToMany association, and the foreign key is stored in LedgerEntry. So we post a new entry by setting its journal and persisting it, without touching the list.

emf.runInTransaction(em -> {
  LedgerEntry milk = new LedgerEntry("Milk", "3.20", LocalDateTime.of(2026, 10, 3, 7, 0));
  milk.setJournal(em.find(Journal.class, journalId));
  em.persist(milk);
});
// insert into LedgerEntry (amount,description,journal_id,postedAt,id) values (?,?,?,?,?)

2.6. Immutable Attributes

On a basic attribute, @Immutable leaves the column out of every UPDATE. The standard @Column attribute updatable = false has the same effect, so both openedOn and owner are skipped when we rename the journal.

journal.setName("Home");
journal.setOpenedOn(LocalDate.of(2025, 1, 1));     // @Immutable
journal.setOwner("Alex");                          // @Column(updatable = false)
// update Journal set memo=?,name=? where id=?
// reloaded: name = Home, openedOn = 2026-01-01, owner = Lokesh

When we change only openedOn and owner, Hibernate sends no UPDATE at all.

3. Other Ways to Make Data Read-Only

The @Immutable annotation fixes the rule in the mapping, for every use case. Hibernate offers other tools for narrower cases, and the right one depends on what must stay unchanged and for how long.

Decision tree: rows never change after insert, use @Immutable on the entity; only some columns are fixed, use @Column(updatable = false) or @Immutable on the attribute; read-only only in one use case, use a read-only query or session; data computed by SQL, use @Subselect with @Immutable; values only displayed, use a record projection
Pick the option by asking what must stay unchanged and for how long.

3.1. Loading a Mutable Entity as Read-Only

A report or an export reads many entities and changes none. We can load a mutable Journal in read-only mode for one query or one session. The entity then has no snapshot, as an @Immutable entity, and changes to it are ignored.

em.createQuery("from Journal", Journal.class)
    .setHint(HibernateHints.HINT_READ_ONLY, true)      // hint name "org.hibernate.readOnly"
    .getSingleResult();

session.createSelectionQuery("from Journal", Journal.class)
    .setReadOnly(true)
    .getSingleResult();

em.find(Journal.class, journalId, ReadOnlyMode.READ_ONLY);

session.setDefaultReadOnly(true);                    // every entity loaded afterwards
session.find(Journal.class, journalId);

journal.setName("Ignored");                          // no UPDATE in all four cases

In the snippet, session is em.unwrap(Session.class). Read-only mode belongs to the object, not to the mapping, so we can switch it back.

Journal journal = session.find(Journal.class, journalId, ReadOnlyMode.READ_ONLY);
session.setReadOnly(journal, false);
journal.setName("Family");
// update Journal set memo=?,name=? where id=?

A read-only entity can still be removed with em.remove(), as an @Immutable one.

3.2. Mapping a SQL Query With @Subselect

A journal balance is computed from the entries, so it has no table of its own. The @Subselect annotation maps an entity to a SQL query, and @Immutable marks it read-only because Hibernate cannot write to a query. The @Synchronize annotation names the tables the query reads, so Hibernate flushes pending changes to those tables first.

@Entity
@Immutable
@Subselect("""
    select j.id as journalId, j.name as name, sum(e.amount) as balance
    from Journal j join LedgerEntry e on e.journal_id = j.id
    group by j.id, j.name""")
@Synchronize({"Journal", "LedgerEntry"})
public class JournalBalance {

  @Id
  private Long journalId;

  private String name;
  private BigDecimal balance;
}
em.persist(milk);
List<JournalBalance> balances = em.createQuery("from JournalBalance", JournalBalance.class).getResultList();
// insert into LedgerEntry (amount,description,journal_id,postedAt,id) values (?,?,?,?,?)
// select jb1_0.journalId,jb1_0.balance,jb1_0.name from ( select j.id as journalId, ... ) jb1_0
// [Household 55.70]

The INSERT runs before the SELECT because of @Synchronize, so the balance includes the milk (40.00 + 12.50 + 3.20). An HQL update of JournalBalance fails with the same HibernateException as in section 2.4.

3.3. Java Records as Read-Only Results

A Java record is immutable by design, because it has no setters. Hibernate cannot use a record as an entity, but it can return records from a query. The persistence context does not track them, so there is nothing to flush.

public record EntryLine(String description, BigDecimal amount) {}

List<EntryLine> lines = em.createQuery("select e.description, e.amount from LedgerEntry e order by e.postedAt", EntryLine.class)
    .getResultList();
// select le1_0.description,le1_0.amount from LedgerEntry le1_0 order by le1_0.postedAt
// [EntryLine[description=Coffee beans, amount=40.00], EntryLine[description=Pancakes, amount=12.50]]

Mapping a record with @Entity compiles and even inserts, but loading it fails because Hibernate needs a no-argument constructor.

org.hibernate.InstantiationException: No default constructor for entity 'com.howtodoinjava.hibernate.immutable.LedgerEntryRecord'

3.4. Comparing the Options

Each option blocks a different set of writes. Only @Immutable on the entity also blocks HQL and JPQL updates. The column-level and read-only options protect only the objects Hibernate loads.

OptionScopeChanges to loaded objectsHQL/JPQL updateStandard JPA
@Immutable on the entityEvery load of the entityIgnored, no snapshotHibernateException by defaultNo
@Immutable on a collectionOne collectionHibernateException at commitNot applicableNo
@Immutable on an attributeOne columnIgnoredRunsNo
@Column(updatable = false)One columnIgnoredRunsYes
Read-only hint, setReadOnly(), ReadOnlyModeOne query, one object or one sessionIgnored, no snapshotRunsNo
@Subselect + @ImmutableEvery load of the query entityIgnoredHibernateException by defaultNo
Record projectionOne queryNo settersNot applicableYes

4. Hibernate @Immutable FAQs

4.1. Does @Immutable Make the Java Object Immutable?

No. The class still compiles with setters, and a setter still changes the object in memory. Hibernate ignores the change only when it writes to the database. To make the object itself immutable, remove the setters and keep a protected no-argument constructor for Hibernate. (The example project keeps one setter only to show that Hibernate ignores it.)

protected LedgerEntry() {
}

public LedgerEntry(String description, String amount, LocalDateTime postedAt) {
  this.description = description;
  this.amount = new BigDecimal(amount);
  this.postedAt = postedAt;
}

4.2. Is @Immutable Part of JPA?

No. It is org.hibernate.annotations.Immutable, so code that uses it works only with Hibernate. Jakarta Persistence 3.2 has no read-only entity, but two standard tools come close.

  • We set @Column(updatable = false) on each field, which leaves the column out of the UPDATE statements Hibernate generates.
  • We use record projections for data that we only display.

4.3. How Do I Change an @Immutable Entity When I Have To?

A correction in a ledger is a new entry, so we persist one. When a row must change anyway, for example to fix a data error, we have two options.

  • A native SQL update, which Hibernate does not check (section 2.4).
  • An HQL or JPQL update with immutable_entity_update_query_handling_mode set to allow.

Both options bypass the persistence context. An entry that is already loaded keeps its old values until we reload it.

LedgerEntry coffee = em.find(LedgerEntry.class, coffeeId);   // "Coffee beans"
// native update sets description = 'Tea'
String cached = em.find(LedgerEntry.class, coffeeId).getDescription();   // still "Coffee beans"
em.refresh(coffee);
String reloaded = coffee.getDescription();                               // "Tea"

4.4. Can @Immutable Go on an AttributeConverter?

Yes. An AttributeConverter turns a Java type into a column value. The @Immutable annotation on the converter class tells Hibernate that the Java type never changes in place, so Hibernate does not compare its internal state. Our Memo class is mutable and is stored in one column.

@Converter
@Immutable
public class MemoConverter implements AttributeConverter<Memo, String> { ... }

@Convert(converter = MemoConverter.class)
private Memo memo;
journal.getMemo().setText("Weekly");        // no UPDATE, memo stays "Monthly"
journal.setMemo(new Memo("Weekly"));        // update Journal set memo=?,name=? where id=?

With an immutable converter, we always assign a new object instead of changing the old one.

4.5. What Replaced the warning Default for Bulk Updates?

Before Hibernate 7, the default mode was warning, so an HQL or JPQL update on an immutable entity ran and Hibernate only logged a warning. Hibernate 7 changed the default to exception, so a query that worked with Hibernate 6 fails at createQuery() in Hibernate 7. We either rewrite the query or set hibernate.query.immutable_entity_update_query_handling_mode to allow (section 2.4).

5. Conclusion

The @Immutable annotation on an entity tells Hibernate to load it read-only and skip it during dirty checking, so Hibernate never sends an UPDATE for it, while persist() and remove() keep working. On a collection, it turns any change into an exception at commit, and since Hibernate 7 it also blocks HQL and JPQL update queries by default. For data that is read-only in only one place, a read-only query or session does the same job without changing the mapping.

The complete ledger project runs every case in this article and checks it with 32 JUnit tests (mvn -q compile exec:java, mvn test).

6. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.