Skip to content

Hibernate & JPA

Mid / Senior

The 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

  1. 01JPA vs Hibernate vs Spring Data JPA
    • JPA (Jakarta Persistence) — a specification: annotations (@Entity, @OneToMany), the EntityManager API, 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). Session extends EntityManager; 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 by SimpleJpaRepository, including derived queries from method names (findByStatusAndCreatedAtAfter).
    #c-jpa-vs-hibernate-vs-spring-data-jpa
  2. 02The persistence context — first-level cache and identity guarantee

    The 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.

    #c-the-persistence-context-first-level-cache-and-identity-guara
  3. 03Entity states — transient, managed, detached, removed
    State 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 value
    Detached Has an id and a DB row but no persistence context tracks it; changes are ignored transaction/context ends, detach(), clear(), serialized and sent elsewhere
    Removed Managed but scheduled for DELETE at flush remove()

    Most “my change wasn’t saved” and LazyInitializationException bugs are a detached entity treated as if it were managed.

    #c-entity-states-transient-managed-detached-removed
  4. 04persist vs merge — and what Spring Data save() does
    • persist(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 a SELECT if 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 flushed
    

    Spring Data save(entity) calls persist if the entity is new, otherwise merge. “New” means the id is null (or a primitive 0), or the @Version field is null, or — for assigned ids — what your Persistable.isNew() returns. Gotcha: with manually assigned ids, save() thinks the entity isn’t new and does merge → an extra SELECT before every INSERT. Implement Persistable or use @Version to 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 calls merge(), which simply returns the same managed instance, and the UPDATE (if anything changed) still happens at flush. Redundant but harmless.

    #c-persist-vs-merge-and-what-spring-data-save-does
  5. 05Flushing — flush vs commit, and flush modes

    Flush = 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; COMMIT flushes only at commit (queries may see stale data); Hibernate’s MANUAL flushes only when you call flush() — what readOnly = true transactions 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.

    #c-flushing-flush-vs-commit-and-flush-modes
  6. 06`find` vs a JPQL query for the same id — cache lookup vs always-SQL

    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 = 1 always runs SQL (after an auto-flush) — though the returned instance is still the already-managed one if present (identity guarantee).

    #c-find-vs-a-jpql-query-for-the-same-id-cache-lookup-vs-always-
  7. 07Primary key generation strategies
    • IDENTITY — auto-increment column. Hibernate must INSERT immediately on persist to 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.
    #c-primary-key-generation-strategies
  8. 08Lazy vs eager loading — and the defaults to override
    Association JPA default fetch
    @ManyToOne, @OneToOne EAGER
    @OneToMany, @ManyToMany LAZY

    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 @ManyToOne on a list of results triggers its own select).

    FetchType.EAGER on 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 cause MultipleBagFetchException (or, with Sets, 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.

    #c-lazy-vs-eager-loading-and-the-defaults-to-override
  9. 09LazyInitializationException and Open Session in View

    LazyInitializationException: 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 touched order.getItems()).

    Fixes, best first:

    1. Fetch what you need in the transaction — JOIN FETCH, @EntityGraph, or a DTO projection.
    2. Map to DTOs inside the service and never let entities leave the transactional layer.
    3. Call Hibernate.initialize(order.getItems()) in the transaction (explicit, but still N+1-ish).

    Open Session in View (OSIV) keeps the EntityManager open 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 to false.

    #c-lazyinitializationexception-and-open-session-in-view
  10. 10The N+1 select problem

    Loading 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 queries
    

    Fixes:

    • JOIN FETCH in 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 into where 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 LIMIT to 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 two List collections at once is rejected (and would produce a Cartesian product anyway). Use Set, or fetch the collections in separate queries.
    • Hibernate 6 de-duplicates parent entities from collection fetch joins automatically; distinct is no longer needed for that.
    #c-the-n1-select-problem
  11. 11Relationship ownership — mappedBy and the owning side

    In a bidirectional association, exactly one side owns the relationship: the side holding the foreign key — the @ManyToOne side, or the side without mappedBy. 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 @OneToMany without @JoinColumn creates an extra join table and inefficient insert-then-update SQL — prefer bidirectional with mappedBy, or just the @ManyToOne.

    #c-relationship-ownership-mappedby-and-the-owning-side
  12. 12Cascade vs orphanRemoval
    • cascade propagates 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 — never CascadeType.REMOVE on @ManyToMany or towards a shared entity like Customer.
    • orphanRemoval = true deletes a child when it is removed from the parent’s collection (or the reference is nulled), even though nobody called remove() on it.

    cascade = REMOVE handles “parent deleted”; orphanRemoval additionally handles “child detached from the parent”.

    #c-cascade-vs-orphanremoval
  13. 13equals and hashCode for entities

    Entities go into Sets and are compared across persistence contexts, and the id is null until persist, so naive implementations break:

    • Default Object identity — 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 a HashSet.
    • 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 equals by id when non-null plus a constant hashCode (getClass().hashCode()). Also compare using Hibernate.getClass()/instanceof rather than getClass() ==, because a lazy proxy is a subclass.

    #c-equals-and-hashcode-for-entities
  14. 14Optimistic locking with @Version
    @Version private long version;
    

    Every UPDATE becomes update … set …, version = version + 1 where id = ? and version = ?. If another transaction updated the row in the meantime, zero rows match and Hibernate throws OptimisticLockException (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.

    #c-optimistic-locking-with-version
  15. 15Pessimistic 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 other FOR UPDATE readers) 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.timeout hint).

    #c-pessimistic-locking
  16. 16getReferenceById vs findById
    • findById(id) — returns the entity (from the persistence context or a SELECT), or Optional.empty() if missing.
    • getReferenceById(id) (formerly getOne) — 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 get EntityNotFoundException only when the proxy is first used (or an FK violation at flush).
    #c-getreferencebyid-vs-findbyid
  17. 17Bulk 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/DELETE go straight to the database: managed entities already in the persistence context keep their stale values, @Version isn’t incremented unless you do it in the query, lifecycle callbacks don’t fire, and cascades don’t apply. flushAutomatically writes pending changes first; clearAutomatically evicts stale entities afterwards. In exchange, one statement replaces thousands of SELECT + UPDATE round trips.

    #c-bulk-updates-bypass-the-persistence-context
  18. 18JDBC batching

    By default each INSERT/UPDATE is 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: true
    

    Requirements and gotchas: doesn’t work for inserts with IDENTITY ids (use SEQUENCE); for big imports, flush() and clear() every batch-size entities so the persistence context doesn’t grow unbounded; with PostgreSQL, reWriteBatchedInserts=true on the JDBC URL turns a batch into one multi-row INSERT.

    #c-jdbc-batching
  19. 19Second-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; helps find() 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.

    #c-second-level-cache-and-query-cache
  20. 20Entity lifecycle callbacks and auditing

    JPA callbacks — @PrePersist, @PostPersist, @PreUpdate, @PostUpdate, @PreRemove, @PostRemove, @PostLoad — run on the entity or on an @EntityListeners class. Spring Data’s auditing builds on this: @EnableJpaAuditing + @CreatedDate, @LastModifiedDate, @CreatedBy (from an AuditorAware bean). @PreUpdate fires only when dirty checking finds a change at flush, and none of these fire for bulk JPQL statements.

    #c-entity-lifecycle-callbacks-and-auditing
  21. 21`spring.jpa.hibernate.ddl-auto` — schema generation, and what production should use

    Hibernate schema generation: create, create-drop, update, validate, none. Production should use validate or none and manage schema changes with Flyway or Liquibase migrations — update can’t rename or drop columns safely and gives no reviewable history.

    #c-springjpahibernateddl-auto-schema-generation-and-what-produc
  22. 22Logging the SQL Hibernate executes — show-sql, loggers, and datasource proxies

    spring.jpa.show-sql=true prints SQL to stdout (no parameter values). Better: set the loggers org.hibernate.SQL=DEBUG and org.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.

    #c-logging-the-sql-hibernate-executes-show-sql-loggers-and-data