Skip to main content

Mastering Hibernate 7 @Immutable Entities: Performance, Safety, and Best Practices

@Immutable is a pure marker annotation that skips Hibernate’s dirty-checking snapshot — mutate a field and flush, and nothing happens: no UPDATE, no exception, just silent data loss of the in-memory change. This rewrite runs every claim against Hibernate 7.4.5 — entity mutation, bulk HQL (rejected at translation time by default, a correction neither prior version made), collection mutation, @Immutable+@Version, and a real flush-cost measurement — and compares the annotation head-to-head with Session.setReadOnly().

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.

PartRead this if…
1 — BeginnerYou want to know what @Immutable does and see it actually stop an UPDATE
2 — IntermediateYou need to know what it does not stop — deletes, native SQL, bulk HQL — and how to read the exceptions each one throws
3 — AdvancedYou’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.

Same mutation, same commit() — two different entities Ordinary @Entity setRate(999.9999) EntityEntry.loadedState = [0.9200] flush(): compare current vs snapshot diff found on ‘rate’ UPDATE exchange_rate SET rate=… Statistics.entityUpdateCount = 1 @Immutable entity setRate(999.9999) EntityEntry.loadedState = null flush(): sees null snapshot comparison skipped entirely (no SQL issued) Statistics.entityUpdateCount = 0 Neither call throws. The ordinary entity gets the write it asked for; the @Immutable one silently discards it.

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. @Immutable only removes the entity from dirty-checking; it says nothing about the persister’s ability to issue a DELETE when you explicitly ask for one. Verified: em.remove(managed) followed by em.find() returns null.
  • Native SQL always works, unconditionally. It never touches Hibernate’s entity-state machinery, so there is no layer for @Immutable to 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 @Immutable the 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 is org.hibernate.cfg.QuerySettings.IMMUTABLE_ENTITY_UPDATE_QUERY_HANDLING_MODE — a SessionFactory-wide three-way enum (EXCEPTION default, WARNING, ALLOW), not a per-query hint. Set it to ALLOW and the bulk update actually executes against the immutable table — confirmed in a separate Spring context: with handling-mode=allow, bulk HQL UPDATE rowsAffected=1. Bulk delete via HQL, by contrast, is not blocked at all — only bulk update carries 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 UPDATE to an is_deleted flag; @Immutable discards 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/@PostLoad that 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:

@ImmutablesetReadOnly() / setDefaultReadOnly()
ScopeClass-level, every instance, every sessionPer entity instance, or per session
DecidedAt mapping time (compile time)At runtime, per Session
ReversibleNo — needs a redeployYes, per instance or per session
Snapshot costNever allocated at allStill 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
flush() over 4,000 loaded rows, zero pending changes mutable 8.63 ms @Immutable 2.54 ms ~3.4x difference — purely the cost of comparing 12 fields per row vs. skipping the comparison entirely. Single run, shared container: indicative of direction and magnitude only, not a benchmark result.

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 DELETE permission for the application’s DB user, or add a BEFORE DELETE trigger.
  • Application-level: expose repositories without a delete method, 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/JPQL delete — the same class of statement whose update sibling gets blocked, but the delete form 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 @Immutable only 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 — use Session.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 touches DELETE.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.