Every entity Hibernate loads gets a silent tax: a second copy of its state, kept around so that at flush time Hibernate can compare the two and decide whether an UPDATE is needed. For a shopping cart or an order line, that comparison is the whole point — it’s how Hibernate knows what changed. For a currency exchange rate recorded three years ago, or an audit log row nobody will ever touch again, it’s pure overhead: memory for a snapshot that will never differ, and CPU cycles spent proving it.
@org.hibernate.annotations.Immutable tells Hibernate to stop paying that tax for a given entity. What the two live versions of this article undersell is what happens on the other side of that decision: mutating an @Immutable entity doesn’t throw anything. It doesn’t validate, warn, or reject. It just does nothing at all, and that silence is both the performance win and the trap.
This rewrite is built entirely around code that actually ran, against Hibernate ORM 7.4.5.Final — entity mutation, bulk HQL, collection mutation, @Version, and a flush-cost measurement, all captured with Statistics and real exception traces rather than described from memory. One correction stands out enough to flag up front: bulk HQL update against an @Immutable entity is not silently ignored the way a field mutation is — it is rejected outright, before it ever reaches the database.
| Part | Read this if… |
|---|---|
| 1 — Beginner | You want to know what @Immutable does and see it actually stop an UPDATE |
| 2 — Intermediate | You need to know what it does not stop — deletes, native SQL, bulk HQL — and how to read the exceptions each one throws |
| 3 — Advanced | You’re deciding between @Immutable and Session.setReadOnly(), or want real numbers on what the annotation saves |
Verified against: Hibernate ORM 7.4.5.Final, Jakarta Persistence 3.2.0, Spring Boot 4.1.1, H2 2.4.240, Java 25 (Temurin 25.0.4.1 LTS). Every claim below with a specific number or exception message was run, not assumed — the companion repository’s chapter 07 has the full test output.
Part 1 — What @Immutable actually turns off
Take an entity that represents something which, once written, should never change — a historical FX rate is the canonical example, and the one this article sticks with:
@Entity
@Immutable // org.hibernate.annotations.Immutable
public class ExchangeRate {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "rate_seq")
private Long id;
private String pair;
@Column(precision = 19, scale = 4)
private BigDecimal rate;
protected ExchangeRate() {} // JPA
public ExchangeRate(String pair, BigDecimal rate) {
this.pair = pair;
this.rate = rate;
}
public BigDecimal getRate() { return rate; }
// Ordinary setter -- @Immutable is a Hibernate-engine concept, not a Java one.
public void setRate(BigDecimal rate) { this.rate = rate; }
}
Notice the setter is still there. Nothing in Java stops you from calling setRate(...) on a managed instance — @Immutable is not a compiler feature or a Java-level guard. It is a flag Hibernate checks at exactly one moment: flush. Hold onto that setter; it matters again in Part 3.
org.hibernate.annotations.Immutable is, per javap -v against the 7.4.5.Final class file, a pure marker — it targets TYPE, METHOD, and FIELD, and carries zero annotation members. No value(), nothing to configure, no per-field opt-out. Immutability is all-or-nothing at the entity (or collection) level; the only granularity you get is which class or which collection you put it on.
The headline behaviour: silence, not a guard
Mutate a managed @Immutable entity and commit the transaction:
ExchangeRate managed = em.find(ExchangeRate.class, id);
managed.setRate(new BigDecimal("999.9999"));
em.getTransaction().commit(); // flush + commit -- does NOT throw
Verbatim from the test run, using Statistics.getEntityUpdateCount() to prove no SQL went out:
in-memory field mutated to 999.9999, about to flush inside a transaction
Statistics.getEntityUpdateCount() after mutate+flush = 0
reloaded from DB: ExchangeRate{id=3, pair=USD/EUR, rate=0.9200}
Zero UPDATE statements, zero exceptions, and the row is exactly what it was before. This is the part worth building intuition around before anything else: if you were expecting a StaleStateException or some validation failure when someone accidentally mutates one of these entities, you will not get one. You get quiet data loss of the in-memory change — the database keeps whatever it already had, and nothing in the logs tells you your write vanished.
Part 2 — What @Immutable does not stop, and the one that surprises people
Three write paths were tested against an @Immutable entity, and they behave three different ways — which is exactly the kind of detail that gets flattened into “it just blocks updates” if you don’t run it.
- EntityManager.remove() goes through normally.
@Immutableonly removes the entity from dirty-checking; it says nothing about the persister’s ability to issue aDELETEwhen you explicitly ask for one. Verified:em.remove(managed)followed byem.find()returnsnull. - Native SQL always works, unconditionally. It never touches Hibernate’s entity-state machinery, so there is no layer for
@Immutableto intercept.em.createNativeQuery("update exchange_rate set rate = 7.7 where id = ...")updates the row every time. - Bulk HQL
update ... set ...— this is the correction neither source article makes, and it’s the opposite of what the other two paths would lead you to guess. It is tempting to assume bulk HQL bypasses@Immutablethe same way native SQL does, since both skip per-entity dirty checking. It does not.
Hibernate 7.4.5 refuses to even translate the query, before touching the database at all:
bulk HQL 'update ExchangeRate set ...' on an @Immutable entity threw: org.hibernate.query.sqm.InterpretationException: Error interpreting query [The query attempts to update an immutable entity: [exchange_rate] (set 'hibernate.query.immutable_entity_update_query_handling_mode' to suppress)]
If you see InterpretationException naming an immutable entity, that’s your fingerprint. The setting named in the message isorg.hibernate.cfg.QuerySettings.IMMUTABLE_ENTITY_UPDATE_QUERY_HANDLING_MODE— aSessionFactory-wide three-way enum (EXCEPTIONdefault,WARNING,ALLOW), not a per-query hint. Set it toALLOWand the bulk update actually executes against the immutable table — confirmed in a separate Spring context:with handling-mode=allow, bulk HQL UPDATE rowsAffected=1. Bulkdeletevia HQL, by contrast, is not blocked at all — only bulkupdatecarries this guard.
@Immutable on a collection is a separate decision from @Immutable on the entity
You can have a mutable parent with an immutable child collection — the annotation on a @OneToMany is independent of whether the owning entity itself carries it:
@Entity
public class RateWithAuditTrail { // NOT @Immutable itself
@Immutable
@OneToMany(cascade = CascadeType.ALL, fetch = FetchType.LAZY)
private List<AuditTrail> auditTrails = new ArrayList<>();
// ...
}
Add an element to that collection and flush, and — unlike the entity-level case — this one does throw. The exact shape matters if you’re writing a catch clause: the immediate exception is jakarta.persistence.RollbackException wrapping the commit, and its root cause is a plain org.hibernate.HibernateException — there is no dedicated subclass:
adding to an @Immutable collection and flushing threw: jakarta.persistence.RollbackException: Error while committing the transaction [Immutable collection was modified: [com.ankurm.hibernatedemo.immutable.RateWithAuditTrail.auditTrails with owner id '1']]
root cause class: org.hibernate.HibernateException message: Immutable collection was modified: [com.ankurm.hibernatedemo.immutable.RateWithAuditTrail.auditTrails with owner id '1']
Catch HibernateException or inspect the cause chain — there is no more specific type to catch.
@Immutable + @Version: accepted, and exactly as inert as you’d guess
Hibernate 7.4.5 accepts the combination without a bootstrap error. The version column is written once at insert (starting at 0) and never increments afterward — there’s no UPDATE for it to ride along on:
persisted @Immutable+@Version entity, version after insert = 0
after mutate+flush, version = 0, rate = 0.0950
This isn’t a separate code path from the headline behaviour — it’s the same “flush sees a null snapshot, skips the entity entirely” mechanism, applied to an entity that happens to also carry a @Version field. Optimistic locking needs something to lock against; if there’s never a comparison, there’s never a conflict to detect. Including @Version on an @Immutable entity is not wrong, just pointless overhead.
When @Immutable is the wrong choice
- Reference data that changes occasionally — a status description that gets corrected next quarter will silently fail to update, and you won’t get an error telling you why.
- Soft-delete entities — a soft delete is an
UPDATEto anis_deletedflag;@Immutablediscards it the same way it discards any other field mutation. - Anything relying on @Version for real optimistic locking, per above.
- Lifecycle callbacks that mutate state — a
@PrePersist/@PostLoadthat decrypts a field or sets a computed value on an already-persisted, already-loaded entity will have its change silently dropped at flush the same as any other mutation.
Part 3 — @Immutable vs. Session.setReadOnly(), and what the annotation actually saves
Remember the setter from Part 1 — the one Java happily let you call? Its continued existence is what makes a second, narrower tool possible: Session.setReadOnly(entity, true) and Session.setDefaultReadOnly(true) produce the identical observable outcome as @Immutable — zero UPDATEs, no exception — on entities that were never annotated at all:
Session.setReadOnly(entity,true) then mutate+flush -> entityUpdateCount = 0
after setDefaultReadOnly(true) + mutate + flush, rate = 6.9000
The difference is entirely about when the decision is made and how durable it is:
| @Immutable | setReadOnly() / setDefaultReadOnly() | |
|---|---|---|
| Scope | Class-level, every instance, every session | Per entity instance, or per session |
| Decided | At mapping time (compile time) | At runtime, per Session |
| Reversible | No — needs a redeploy | Yes, per instance or per session |
| Snapshot cost | Never allocated at all | Still allocated; the read-only flag is checked at flush instead |
That bottom row — “never allocated at all” — is the one worth measuring rather than asserting, and it’s also where the original article’s specific “nearly doubles memory efficiency” claim doesn’t hold up: that number was never measured, in this repository or apparently anywhere it was checked against, and I’m not carrying an unverified figure forward. What was measured instead is the flush-time symptom of skipping the snapshot.
Does @Immutable actually cost less at flush time? A real measurement
4,000 rows of a 12-column entity, loaded fully into a fresh persistence context with zero pending changes, then a single flush() timed around the load — for both a plain entity and an @Immutable one:
flush() over 4000 loaded MUTABLE rows (12 cols, no pending changes): 8.62815 ms
flush() over 4000 loaded @Immutable rows (12 cols, no pending changes): 2.535115 ms
ratio (mutable / immutable) = 3.403455070085578
Say the caveat plainly: this is one run in a shared container with System.nanoTime(), no JMH, no forked JVM, no warm-up isolation beyond a single discarded pass. Treat the ~3.4x as directionally real, not as a citable throughput number for your own workload. It isolates the right thing, though — both entity sets had zero pending changes, so the delta is entirely the cost of Hibernate deciding “does this need an UPDATE“, not the cost of writing one.
Layering your defence against deletions
@Immutable does not prevent deletions — EntityManager.remove() proved that in Part 2. A “write once” architecture needs more than the annotation:
- Database-level: revoke
DELETEpermission for the application’s DB user, or add aBEFORE DELETEtrigger. - Application-level: expose repositories without a
deletemethod, and gate who can even reach the code path that would call one. - @PreRemove has a hole: a callback that throws works for
EntityManager.remove(), but is bypassed entirely by bulk HQL/JPQLdelete— the same class of statement whoseupdatesibling gets blocked, but thedeleteform does not.
Immutability is not security. If deletion truly must be impossible, enforce it where a Java annotation can’t be reflected around or skipped by a bulk statement — the database grant.
Frequently asked questions
Does @Column(updatable = false) do the same thing?
No. updatable = false is a per-column SQL directive that omits that column from any generated UPDATE. @Immutable is an engine-wide directive that skips the snapshot and comparison for the whole entity (or collection). They compose fine together, but they act at different layers.
Can an @Immutable entity have mutable associations?
Yes — you can repoint a @ManyToOne to a different, mutable target. The immutable entity only protects its own field state, not the graph around it.
Is it a good fit for the second-level cache?
Directionally yes — an entity that never changes after insert has no risk of an L2 cache entry going stale, which is exactly what CacheConcurrencyStrategy.READ_ONLY is built for. I have not re-measured cache-hit behaviour for this combination in this repository, so treat this as reasoned from the no-UPDATE finding above rather than independently benchmarked here — see the Second-Level Cache article for how that cache actually invalidates.
Does it work with Spring Data JPA repositories?save()/saveAll() still INSERT new entities correctly — immutability only touches Hibernate’s update path, not insert. Calling save() again on an already-persisted @Immutable entity follows from everything above: it will silently no-op the update rather than throwing, for the same reason a direct field mutation does.
Should you use it? Reach for@Immutableonly for data that is architecturally never going to change — audit rows, historical snapshots, reference data you version by inserting new rows rather than editing old ones. If the read-only-ness is a per-request or per-session decision instead — a reporting query that loads entities it has no business writing back — useSession.setReadOnly()and keep the entity class itself mutable. And never treat either one as an access-control mechanism: they stop accidental writes, not determined ones, and neither touchesDELETE.
Further reading
- Companion repository: chapter 07 — @Immutable entities, with the full test classes and raw output
- 📘 Hibernate 7 Natural IDs — another mapping-time performance decision that pairs well with @Immutable
- 📘 Hibernate 7 First-Level Cache — the snapshot mechanism @Immutable opts out of
- 📘 Hibernate 7 Second-Level Cache — pairing READ_ONLY caching with @Immutable entities
- 📘 Hibernate 7 Lifecycle Callbacks — why @PrePersist/@PostLoad mutations get lost the same way
- 🔗 Official Hibernate 7 User Guide — entity immutability
No Comments yet!