Hibernate & JPA
Mid / SeniorThe persistence context, entity states, persist vs merge, flushing, lazy loading, N+1, relationships, locking, batching, schema generation, and SQL logging.
Created 5 October 2026 · Updated 23 October 2026
22 concepts · detail hidden
01JPA vs Hibernate vs Spring Data JPA
#c-jpa-vs-hibernate-vs-spring-data-jpa- JPA (Jakarta Persistence) — a specification: annotations (
@Entity,@OneToMany), theEntityManagerAPI, JPQL. No implementation. - Hibernate — the most common JPA implementation (the ORM that actually generates
SQL), plus its own extras (
Session,@BatchSize,@Formula, second-level cache integrations).SessionextendsEntityManager;entityManager.unwrap(Session.class)gets the native API. - Spring Data JPA — a layer on top of JPA: you declare interfaces
(
interface OrderRepository extends JpaRepository<Order, Long>) and Spring generates the implementation at startup as a proxy backed bySimpleJpaRepository, including derived queries from method names (findByStatusAndCreatedAtAfter).
- JPA (Jakarta Persistence) — a specification: annotations (
02The persistence context — first-level cache and identity guarantee
#c-the-persistence-context-first-level-cache-and-identity-guaraThe persistence context (one per
EntityManager; in Spring, normally one per transaction) is a map of(entity type, id) → managed instance, plus a snapshot of each instance’s loaded state. It gives you:- First-level cache —
find(Order.class, 1L)twice in one transaction hits the database once. - Identity guarantee — within one persistence context, the same row is always the same
Java object (
a == b). - Automatic dirty checking — changes to managed objects are detected and written at flush.
- Write-behind — SQL is queued and sent at flush, not when you call setters or
persist.
It is not shared across transactions or threads, and it grows with every entity loaded — processing a million rows in one transaction without
clear()will exhaust memory.- First-level cache —
03Entity states — transient, managed, detached, removed
#c-entity-states-transient-managed-detached-removedState Meaning How you get there Transient (new) Plain object, not known to any persistence context, no DB row new Order()Managed (persistent) Tracked by the current persistence context; changes are flushed automatically persist(),find(), query results,merge()'s return valueDetached Has an id and a DB row but no persistence context tracks it; changes are ignored transaction/context ends, detach(),clear(), serialized and sent elsewhereRemoved Managed but scheduled for DELETEat flushremove()Most “my change wasn’t saved” and
LazyInitializationExceptionbugs are a detached entity treated as if it were managed.04persist vs merge — and what Spring Data save() does
#c-persist-vs-merge-and-what-spring-data-save-doespersist(entity)— makes a transient entity managed; the same instance becomes managed. Throws if the entity is detached.merge(entity)— copies the state of a detached (or new) object onto a managed instance (loading it with aSELECTif needed) and returns that managed instance. The argument itself stays detached — keep using the return value.
Order managed = em.merge(detachedOrder); detachedOrder.setNote("x"); // ignored — not managed managed.setNote("x"); // will be flushedSpring Data
save(entity)callspersistif the entity is new, otherwisemerge. “New” means the id isnull(or a primitive0), or the@Versionfield isnull, or — for assigned ids — what yourPersistable.isNew()returns. Gotcha: with manually assigned ids,save()thinks the entity isn’t new and doesmerge→ an extraSELECTbefore everyINSERT. ImplementPersistableor use@Versionto avoid it.Calling
save()on an entity that is already managed (loaded in the current transaction) does nothing beyond what dirty checking would do anyway:save()sees it isn’t new and callsmerge(), which simply returns the same managed instance, and theUPDATE(if anything changed) still happens at flush. Redundant but harmless.05Flushing — flush vs commit, and flush modes
#c-flushing-flush-vs-commit-and-flush-modesFlush = send pending SQL (
INSERT/UPDATE/DELETE) to the database within the current transaction. Commit = make the transaction’s changes durable and visible to others. Flushed but uncommitted changes are still rolled back on rollback.Flush happens:
- before commit;
- before executing a query that may be affected by pending changes (so a JPQL query sees
your own unflushed
persist), and before native SQL queries; - on explicit
flush()/saveAndFlush().
FlushModeType.AUTO(default) behaves as above;COMMITflushes only at commit (queries may see stale data); Hibernate’sMANUALflushes only when you callflush()— whatreadOnly = truetransactions use.Action ordering at flush: Hibernate executes inserts, then updates, then collection changes, then deletes last — regardless of the order you called the methods. Deleting a row and inserting a new row with the same unique key in one transaction therefore fails with a constraint violation; flush explicitly after the delete.
06`find` vs a JPQL query for the same id — cache lookup vs always-SQL
#c-find-vs-a-jpql-query-for-the-same-id-cache-lookup-vs-always-em.find(Order.class, 1L)checks the persistence context first and only hits the database on a miss.select o from Order o where o.id = 1always runs SQL (after an auto-flush) — though the returned instance is still the already-managed one if present (identity guarantee).07Primary key generation strategies
#c-primary-key-generation-strategiesIDENTITY— auto-increment column. Hibernate mustINSERTimmediately onpersistto learn the id, which disables JDBC insert batching.SEQUENCE— a database sequence; with the pooled optimizer (allocationSize = 50) Hibernate fetches a block of ids in one call and assigns them in memory, so inserts can be batched. Preferred on PostgreSQL/Oracle.TABLE— emulates a sequence with a table and row locks; slow, avoid.UUID— generated in the app, no round trip; random UUIDv4 keys fragment B-tree indexes — time-ordered UUIDv7 avoids that.
08Lazy vs eager loading — and the defaults to override
#c-lazy-vs-eager-loading-and-the-defaults-to-overrideAssociation JPA default fetch @ManyToOne,@OneToOneEAGER @OneToMany,@ManyToManyLAZY Best practice: make everything
LAZY(@ManyToOne(fetch = FetchType.LAZY)) and fetch what each use case needs explicitly per query. Eager associations can’t be “turned off” per query, are loaded even when unused, and are a common source of hidden N+1 queries (each eager@ManyToOneon a list of results triggers its own select).FetchType.EAGERon a collection is worse still:- the collection is loaded every time the parent is, in every use case, with no per-query opt-out;
- two eager
Lists on one entity causeMultipleBagFetchException(or, withSets, a Cartesian product that multiplies the rows returned); - when the parent is loaded by a query rather than
find(), the eager collection is often fetched with N extra selects rather than a join.
A lazy association is a proxy (for single-valued) or a
PersistentBag/PersistentSet(for collections) that loads on first access — which requires an open persistence context.09LazyInitializationException and Open Session in View
#c-lazyinitializationexception-and-open-session-in-viewLazyInitializationException: could not initialize proxy – no Session— you accessed a lazy association on an entity whose persistence context is already closed (typically: a service returned an entity, the transaction ended, and the controller or Jackson serialization touchedorder.getItems()).Fixes, best first:
- Fetch what you need in the transaction —
JOIN FETCH,@EntityGraph, or a DTO projection. - Map to DTOs inside the service and never let entities leave the transactional layer.
- Call
Hibernate.initialize(order.getItems())in the transaction (explicit, but still N+1-ish).
Open Session in View (OSIV) keeps the
EntityManageropen for the whole HTTP request so lazy loading “just works” in controllers and views. Spring Boot enables it by default (spring.jpa.open-in-view=true) and logs a warning at startup. Downsides: database connections can be held for the entire request including view rendering, lazy loads happen outside any transaction (auto-commit, one statement each), and N+1 problems are hidden rather than fixed. Many teams set it tofalse.- Fetch what you need in the transaction —
10The N+1 select problem
#c-the-n1-select-problemLoading N parent rows with one query, then one additional query per parent to load an association:
List<Order> orders = orderRepo.findAll(); // 1 query for (Order o : orders) o.getCustomer().getName(); // + N queriesFixes:
JOIN FETCHin JPQL:select o from Order o join fetch o.customer— one query.@EntityGraph(attributePaths = "customer")on a Spring Data method.@BatchSize(size = 50)/hibernate.default_batch_fetch_size— lazy loads are batched intowhere id in (…), turning N queries into N/50.- DTO projections — select only the columns you need.
Detect it with SQL logging or Hibernate statistics, or assert query counts in tests.
Related gotchas:
- Fetch join + pagination on a collection: Hibernate can’t apply
LIMITto a row-multiplied result, so it loads everything and paginates in memory (warning HHH90003004 — Hibernate 6 can be configured to fail instead). Page the parent ids first, then fetch-join for those ids. MultipleBagFetchException— fetch-joining twoListcollections at once is rejected (and would produce a Cartesian product anyway). UseSet, or fetch the collections in separate queries.- Hibernate 6 de-duplicates parent entities from collection fetch joins automatically;
distinctis no longer needed for that.
11Relationship ownership — mappedBy and the owning side
#c-relationship-ownership-mappedby-and-the-owning-sideIn a bidirectional association, exactly one side owns the relationship: the side holding the foreign key — the
@ManyToOneside, or the side withoutmappedBy. Only the owning side’s state is written to the database.@Entity class Order { @OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true) private List<OrderLine> lines = new ArrayList<>(); public void addLine(OrderLine l) { lines.add(l); l.setOrder(this); } // keep both sides in sync public void removeLine(OrderLine l) { lines.remove(l); l.setOrder(null); } } @Entity class OrderLine { @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "order_id") private Order order; // owning side — the FK lives here }order.getLines().add(line)alone sets no foreign key. Also, a unidirectional@OneToManywithout@JoinColumncreates an extra join table and inefficient insert-then-update SQL — prefer bidirectional withmappedBy, or just the@ManyToOne.12Cascade vs orphanRemoval
#c-cascade-vs-orphanremovalcascadepropagates entity-manager operations from parent to children:PERSIST(persisting the order persists new lines),MERGE,REMOVE(deleting the order deletes its lines),ALL. Use it only for genuine parent–child composition — neverCascadeType.REMOVEon@ManyToManyor towards a shared entity likeCustomer.orphanRemoval = truedeletes a child when it is removed from the parent’s collection (or the reference is nulled), even though nobody calledremove()on it.
cascade = REMOVEhandles “parent deleted”;orphanRemovaladditionally handles “child detached from the parent”.13equals and hashCode for entities
#c-equals-and-hashcode-for-entitiesEntities go into
Sets and are compared across persistence contexts, and the id isnulluntil persist, so naive implementations break:- Default
Objectidentity — fine within one persistence context (identity guarantee), wrong across contexts. - All fields (e.g. Lombok
@Data) — touches lazy associations, and the hash changes whenever a field changes, so the entity gets “lost” inside aHashSet. - Generated id only — the hash changes when the id is assigned at persist.
Good options: a natural/business key (an immutable unique value like an ISBN), or
equalsby id when non-null plus a constanthashCode(getClass().hashCode()). Also compare usingHibernate.getClass()/instanceofrather thangetClass() ==, because a lazy proxy is a subclass.- Default
14Optimistic locking with @Version
#c-optimistic-locking-with-version@Version private long version;Every
UPDATEbecomesupdate … set …, version = version + 1 where id = ? and version = ?. If another transaction updated the row in the meantime, zero rows match and Hibernate throwsOptimisticLockException(Spring:ObjectOptimisticLockingFailureException). No locks are held while the user thinks — ideal for low-contention data and for long conversations (load in request 1, submit in request 2: the version travels with the form/DTO). Handle the exception by retrying the whole read-modify-write or telling the user the record changed.15Pessimistic locking
#c-pessimistic-locking@Lock(LockModeType.PESSIMISTIC_WRITE) @Query("select a from Account a where a.id = :id") Optional<Account> findForUpdate(long id);PESSIMISTIC_WRITE→SELECT … FOR UPDATE: the row is locked until the transaction ends; other writers (and otherFOR UPDATEreaders) wait.PESSIMISTIC_READ→ a shared lock (FOR SHARE). Use for high-contention hot rows where retrying optimistic failures would be worse (account balance, stock counter). Costs: blocking, deadlock risk (always lock rows in a consistent order), and you must set a lock timeout (jakarta.persistence.lock.timeouthint).16getReferenceById vs findById
#c-getreferencebyid-vs-findbyidfindById(id)— returns the entity (from the persistence context or aSELECT), orOptional.empty()if missing.getReferenceById(id)(formerlygetOne) — returns an uninitialised proxy without touching the database. Ideal for setting a foreign key:line.setOrder(orderRepo.getReferenceById(orderId))inserts the line without loading the order. If the row doesn’t exist, you getEntityNotFoundExceptiononly when the proxy is first used (or an FK violation at flush).
17Bulk updates bypass the persistence context
#c-bulk-updates-bypass-the-persistence-context@Modifying(clearAutomatically = true, flushAutomatically = true) @Query("update Product p set p.price = p.price * 1.1 where p.category = :c") int raisePrices(String c);JPQL/native
UPDATE/DELETEgo straight to the database: managed entities already in the persistence context keep their stale values,@Versionisn’t incremented unless you do it in the query, lifecycle callbacks don’t fire, and cascades don’t apply.flushAutomaticallywrites pending changes first;clearAutomaticallyevicts stale entities afterwards. In exchange, one statement replaces thousands ofSELECT+UPDATEround trips.18JDBC batching
#c-jdbc-batchingBy default each
INSERT/UPDATEis a separate round trip. Enable batching:spring.jpa.properties.hibernate: jdbc.batch_size: 50 order_inserts: true # group inserts by entity type so batches aren't broken up order_updates: trueRequirements and gotchas: doesn’t work for inserts with
IDENTITYids (useSEQUENCE); for big imports,flush()andclear()every batch-size entities so the persistence context doesn’t grow unbounded; with PostgreSQL,reWriteBatchedInserts=trueon the JDBC URL turns a batch into one multi-rowINSERT.19Second-level cache and query cache
#c-second-level-cache-and-query-cache- First-level cache — the persistence context; per transaction, always on.
- Second-level (L2) cache — shared across sessions in the
EntityManagerFactory(Ehcache, Caffeine via JCache, Infinispan, Hazelcast), opt-in per entity with@Cacheable+@Cache(usage = READ_WRITE). Stores entity state by id; helpsfind()and association loads, not arbitrary queries. Best for read-mostly reference data. - Query cache — caches the ids returned by a query; invalidated whenever any of the queried tables changes, so it only helps rarely-changing tables.
Pitfalls: updates made outside Hibernate (other services, SQL scripts, bulk JPQL) leave the L2 cache stale, and a local cache per instance diverges in a cluster unless it’s distributed or invalidated.
20Entity lifecycle callbacks and auditing
#c-entity-lifecycle-callbacks-and-auditingJPA callbacks —
@PrePersist,@PostPersist,@PreUpdate,@PostUpdate,@PreRemove,@PostRemove,@PostLoad— run on the entity or on an@EntityListenersclass. Spring Data’s auditing builds on this:@EnableJpaAuditing+@CreatedDate,@LastModifiedDate,@CreatedBy(from anAuditorAwarebean).@PreUpdatefires only when dirty checking finds a change at flush, and none of these fire for bulk JPQL statements.21`spring.jpa.hibernate.ddl-auto` — schema generation, and what production should use
#c-springjpahibernateddl-auto-schema-generation-and-what-producHibernate schema generation:
create,create-drop,update,validate,none. Production should usevalidateornoneand manage schema changes with Flyway or Liquibase migrations —updatecan’t rename or drop columns safely and gives no reviewable history.22Logging the SQL Hibernate executes — show-sql, loggers, and datasource proxies
#c-logging-the-sql-hibernate-executes-show-sql-loggers-and-dataspring.jpa.show-sql=trueprints SQL to stdout (no parameter values). Better: set the loggersorg.hibernate.SQL=DEBUGandorg.hibernate.orm.jdbc.bind=TRACE(Hibernate 6) for bound parameters, or use a proxy like datasource-proxy / p6spy, which also lets tests assert on query counts to catch N+1 regressions.