Top 40 Hibernate and JPA Interview Questions (2026) with Runnable Answers
40 Hibernate and JPA interview questions answered with tests run against Hibernate 7.4 and PostgreSQL 16: entity states, N+1, fetch types, caching, locking and querying, each with real captured output.
The interview question is never “what is lazy loading”. It is “what happens if I read this collection after the session closes?” — and a memorised definition does not survive that follow-up. This page answers 40 Hibernate and JPA questions the other way round: every answer is a test that was run against Hibernate 7 and a real PostgreSQL 16, and the output shown under each answer is the file the test wrote.
JPA is the standard (the jakarta.persistence API); Hibernate is the implementation most Java teams use. The questions are grouped the way interviews tend to go: entity states, mapping, associations and fetching, caching, locking, and querying. Each question is a collapsible block, so you can read the one-line answer, open the code and output only when you want to check it, and follow the link to the longer ankurm.com article on that topic.
Tested with: Hibernate ORM 7.4.13.Final, PostgreSQL 16.15 with pgjdbc 42.7.14, JUnit 6.1.3, JDK 25.0.5 (Temurin), Maven 3.9.11. Versions from the Maven Central directory index on 2026-10-24. All code is in java-interview-lab/hibernate-jpa; scripts/run-all.sh regenerates every output/qNN-*.txt file quoted below, and two consecutive runs produced identical files.
Reading order. The page starts with what a Session does to your objects and ends with bulk updates, because later answers lean on earlier ones — the lost-update answer in the locking section only makes sense once you have seen dirty checking. If you are short on time, the section titled “Four answers that changed” near the end lists the places where running the code contradicted what is commonly repeated.
Entity states and the Session: what Hibernate does with your objects
A Hibernate Session keeps a map of the entities it is watching. Everything in this section follows from which objects are in that map and which are not.
The diagram is the answer to most “what happens if” questions in this section: only objects in the Managed box produce SQL. A transient object you forgot to persist, or a detached one you changed after the session closed, is just a Java object. The tests below move one Employee around the diagram and ask the session contains() at every step.
Q1. What is the difference between JPA and Hibernate?
JPA is a specification: interfaces such as EntityManager and annotations such as @Entity. Hibernate implements it and adds its own API. In Hibernate, Sessionis an EntityManager and SessionFactoryis an EntityManagerFactory, so one object offers both vocabularies.
Test code, from A_FundamentalsTest.java:
EntityManager em = s;
p("Session is an EntityManager : %s", s instanceof EntityManager);
p("SessionFactory is an EMF : %s", sf instanceof EntityManagerFactory);
p("em.unwrap(Session.class) == em : %s", em.unwrap(Session.class) == em);
Session is an EntityManager : true
SessionFactory is an EMF : true
em.unwrap(Session.class) == em : true
Hibernate-only API reachable : s.getStatistics().isStatisticsEnabled() = true
Go deeper:Hibernate 7 with Spring Boot 4Q2. What are the entity states, and how does an object move between them?
Transient (new, unknown to any session), managed (tracked by an open session), detached (was managed, session gone or told to forget it) and removed (scheduled for deletion). persist makes a transient object managed; detach, clear or closing the session detaches it; merge copies a detached object onto a managed one; remove schedules the delete.
Test code, from A_FundamentalsTest.java:
Employee e = new Employee("Asha", 100, null);
try (Session s = sf.openSession()) {
Transaction tx = s.beginTransaction();
p("new Employee() : managed=%s id=%s", s.contains(e), e.id);
s.persist(e);
p("after persist() : managed=%s id assigned=%s", s.contains(e), e.id != null);
tx.commit();
s.detach(e);
p("after detach() : managed=%s", s.contains(e));
Transaction tx2 = s.beginTransaction();
Employee managed = s.merge(e);
p("after merge() : original managed=%s, returned copy managed=%s, same object=%s", s.contains(e), s.contains(managed), e == managed);
s.remove(managed);
p("after remove() : managed=%s", s.contains(managed));
tx2.commit();
new Employee() : managed=false id=null
after persist() : managed=true id assigned=true
after detach() : managed=false
after merge() : original managed=false, returned copy managed=true, same object=false
after remove() : managed=false
Go deeper:The first-level cache explainedQ3. What is the difference between persist() and merge()?persist is for new objects: the very instance you pass becomes managed. Passing it an object that already has an identity and is detached fails with EntityExistsException. merge is for detached objects: it loads (or creates) a managed copy, copies your state onto it and returns the copy. The object you passed in stays detached, so changes made to it afterwards are not saved.
Test code, from A_FundamentalsTest.java:
Employee e = new Employee("Ben", 100, null);
tx(s -> s.persist(e));
// e is now detached (its session is closed)
try {
tx(s -> s.persist(e));
} catch (Exception ex) {
p("persist(detached) -> %s", first(ex));
}
Employee[] copy = new Employee[1];
tx(s -> {
copy[0] = s.merge(e);
e.salary = 777; // change the ORIGINAL after merge
p("merge(detached) -> returns a different instance: %s", copy[0] != e);
});
int salary = txr(s -> s.find(Employee.class, e.id).salary);
p("salary changed on the original after merge is stored: %s (db has %d)", salary == 777, salary);
assertEquals(100, salary);
persist(detached) -> jakarta.persistence.EntityExistsException: Detached entity passed to persist: lab.hibernate.model.Employee
merge(detached) -> returns a different instance: true
salary changed on the original after merge is stored: false (db has 100)
Go deeper:persist, save, merge, saveOrUpdate: the five decisionsQ4. Whatever happened to save(), update() and saveOrUpdate()?
They are gone from org.hibernate.Session in Hibernate 7 — this test lists the methods by reflection instead of trusting a changelog. delete() is gone too. persist, merge and remove are the replacements; find and getReference are the JPA names for get and load, which are still present in 7.4.13. Old tutorials that call session.save(x) do not compile.
Test code, from A_FundamentalsTest.java:
Set<String> wanted = Set.of("save", "update", "saveOrUpdate", "delete", "load", "get", "persist", "merge", "remove", "find", "getReference", "refresh");
Set<String> present = new TreeSet<>();
for (Method m : Session.class.getMethods()) {
if (wanted.contains(m.getName())) {
present.add(m.getName());
}
}
Set<String> missing = new TreeSet<>(wanted);
missing.removeAll(present);
p("methods present on org.hibernate.Session : %s", present);
p("methods NOT present : %s", missing);
Go deeper:Hibernate 5 to 6 to 7 migration guideQ5. What is the difference between find() and getReference()?find runs the SELECT now and returns null when the row is missing. getReference returns a proxy with just the id and runs no SQL until you call a method on it; if the row is missing that call throws org.hibernate.ObjectNotFoundException. One trap the output shows: a proxy intercepts method calls, not field reads, so ref.name on a public field returned null without loading anything while ref.getName() loaded the row.
Test code, from A_FundamentalsTest.java:
Employee ref = s.getReference(Employee.class, id);
p("getReference(): SQL statements so far = %d, initialized = %s", SqlLog.since(m).size(), Hibernate.isInitialized(ref));
String viaField = ref.name; // a public field is read straight off the proxy object
p("ref.name (field) -> %s; SQL statements now = %d, initialized = %s", viaField, SqlLog.since(m).size(), Hibernate.isInitialized(ref));
String name = ref.getName(); // a method call is intercepted and loads the row
p("ref.getName() -> %s; SQL statements now = %d, initialized = %s", name, SqlLog.since(m).size(), Hibernate.isInitialized(ref));
getReference(): SQL statements so far = 0, initialized = false
ref.name (field) -> null; SQL statements now = 0, initialized = false
ref.getName() -> Chen; SQL statements now = 1, initialized = true
SQL> select e1_0.id,e1_0.city,e1_0.zip,e1_0.department_id,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.id=?
find(missing id) -> null
getReference(missing) -> proxy returned without error: true
first access -> org.hibernate.ObjectNotFoundException: No row with the given identifier exists for entity [lab.hibernate.model.Employee with id '99999']
Go deeper:get() vs getReference() · find() vs getReference() decision matrixQ6. What is dirty checking, and when does Hibernate flush?
At flush time Hibernate compares every managed entity with the snapshot it took when the entity was loaded and issues an UPDATE for the ones that differ. That is why no save() is needed after changing a managed object. By default (FlushMode.AUTO) it also flushes before a query that could be affected by pending changes, which is why the count below sees the not-yet-committed insert. Note the UPDATE sets every column — question 36 shows how to change that.
Test code, from A_FundamentalsTest.java:
Employee e = s.find(Employee.class, id);
e.salary = 150; // no save(), no update()
s.persist(new Employee("Eli", 90, null));
long n = s.createSelectionQuery("select count(e) from Employee e", Long.class).getSingleResult();
change a managed entity, no save call:
SQL> select e1_0.id,e1_0.city,e1_0.zip,e1_0.department_id,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.id=?
SQL> update Employee set city=?,zip=?,department_id=?,level=?,name=?,salary=? where id=?
count visible inside the transaction (AUTO flush before the query): 2
persist then query:
SQL> select nextval('Employee_SEQ')
SQL> insert into Employee (city,zip,department_id,level,name,salary,id) values (?,?,?,?,?,?,?)
SQL> select count(e1_0.id) from Employee e1_0
Go deeper:The first-level cache explainedQ7. What is the difference between merge() and refresh()?merge pushes state into the database by way of a managed copy. refresh pulls state from the database over your object, throwing away any pending in-memory change. The output shows both directions on the same row.
Test code, from A_FundamentalsTest.java:
refresh(): pending in-memory change thrown away, salary = 100
merge(detached with salary 500) -> database now has 500
Go deeper:merge() vs refresh()Q8. What is the first-level cache?
The session’s own map of managed entities, always on and not configurable. Two find calls for the same id in one session issue one SELECT and return the same Java instance. clear() empties it. A different session has its own cache, so it queries again.
Test code, from A_FundamentalsTest.java:
Employee a = s.find(Employee.class, id);
Employee b = s.find(Employee.class, id);
p("two find() calls, same session: SQL selects = %d, same instance = %s", SqlLog.countSince(m, "select"), a == b);
s.clear();
Employee c = s.find(Employee.class, id);
two find() calls, same session: SQL selects = 1, same instance = true
after clear(): SQL selects = 2, same instance as before = false
SQL> select e1_0.id,e1_0.city,e1_0.zip,e1_0.department_id,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.id=?
SQL> select e1_0.id,e1_0.city,e1_0.zip,e1_0.department_id,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.id=?
a different session: SQL selects = 1 (the first-level cache is per session)
Mapping questions: ids, enums, inheritance and equals
Mapping questions are usually “what does this annotation do to the table”. The tests ask the database itself through information_schema and raw column reads, so the answers are about what was stored, not what the Javadoc says.
Q9. Why does an entity need a no-argument constructor?
Hibernate creates entity instances itself, through a constructor with no arguments (it may be protected). Without one, the failure is not at startup: persisting worked (the object already existed) and the first find failed with InstantiationException: No default constructor.
Test code, from B_MappingTest.java:
SessionFactory started with an entity that has no no-arg constructor: true
persist(): ok
find() -> org.hibernate.InstantiationException: No default constructor for entity 'lab.hibernate.model.BadEntity'
root cause -> org.hibernate.InstantiationException: No default constructor for entity 'lab.hibernate.model.BadEntity'
Go deeper:JPA persistence annotations: the traps that passQ10. IDENTITY or SEQUENCE — which id strategy, and why does it matter?
With IDENTITY the database assigns the id during the INSERT, so Hibernate must run each insert immediately to learn it, and JDBC batching cannot be used. With SEQUENCE Hibernate asks for ids in blocks and can postpone and batch the inserts. For 25 entities with hibernate.jdbc.batch_size=10 the log shows one prepared statement against 25.
Test code, from B_MappingTest.java:
boot(new String[] {"hibernate.jdbc.batch_size", "10"}, Invoice.class, Receipt.class);
int m = SqlLog.mark();
tx(s -> {
for (int i = 0; i < 25; i++) {
s.persist(new Receipt(i));
}
});
long seqInserts = SqlLog.countSince(m, "insert");
long seqSelects = SqlLog.countSince(m, "select");
p("SEQUENCE: 25 entities -> %d prepared insert statement(s) with jdbc.batch_size=10, %d sequence selects", seqInserts, seqSelects);
List<String> cols = s.createNativeQuery("select column_name from information_schema.columns where table_name='employee' order by 1", String.class).getResultList();
p("columns of table employee: %s", cols);
Go deeper:JPA persistence annotationsQ12. @Enumerated ORDINAL or STRING?@Enumerated(STRING) stores the constant’s name. The default, ORDINAL, stores its position, so inserting or reordering a constant silently changes what existing rows mean. The output shows LEAD stored as 2 under the default.
Test code, from B_MappingTest.java:
Employee e = new Employee("Hana", 1, null);
e.level = Level.LEAD;
s.persist(e);
RankedEmployee r = new RankedEmployee("Hana");
r.level = Level.LEAD;
s.persist(r);
@Enumerated(STRING) stores LEAD as: LEAD
@Enumerated (default) stores LEAD as: 2 (its position in the enum: [JUNIOR, SENIOR, LEAD])
Go deeper:JPA persistence annotationsQ13. How is a List<String> mapped?
With @ElementCollection: Hibernate creates a separate table (post_tags here) holding the owner id and the value. It is not an entity, has no identity of its own, and is lazy by default.
Test code, from B_MappingTest.java:
Go deeper:@OneToMany done rightQ14. What are the inheritance strategies, and what SQL does each produce?SINGLE_TABLE puts the whole hierarchy in one table with a discriminator column (kind below) and nullable subclass columns. JOINED uses one table per class and joins them: the polymorphic query left-joins every subclass table. TABLE_PER_CLASS (not run here) has one full table per concrete class. The tables list and the SQL below show the first two.
Test code, from B_MappingTest.java:
tables: [bankpayment, cardpayment, payment, shape]
JOINED 'from Payment' returned 2 rows
SINGLE_TABLE 'from Shape' returned 2 rows
SQL> select p1_0.id,case when p1_2.id is not null then 1 when p1_1.id is not null then 2 end,p1_0.amount,p1_1.iban,p1_2.last4 from Payment p1_0 left join BankPayment p1_1 on p1_0.id=p1_1.id left join CardPayment p1_2 on p1_0.id=p1_2.id
SQL> select s1_0.id,s1_0.kind,s1_0.label,s1_0.radius,s1_0.side from Shape s1_0
Go deeper:SINGLE_TABLE vs JOINED vs TABLE_PER_CLASSQ15. @MappedSuperclass or @Entity for a shared base class?@MappedSuperclass shares fields and mappings but is not an entity: there is no table for it and it cannot be queried. Its columns are copied into each subclass’s table. Querying it fails with UnknownEntityException.
Test code, from B_MappingTest.java:
tables: [note]
select from the entity Note: 1 row(s), created_by column inherited = system
select from the @MappedSuperclass -> org.hibernate.query.sqm.UnknownEntityException: Could not resolve root entity 'Auditable'
Go deeper:Inheritance mappingQ16. Why is hashCode() on a generated id dangerous?
The id is null until the entity is persisted. An object added to a HashSet before that is filed under the hash of null; after persist its hash changes and the set can no longer find it. Use a natural key, or a fixed hash with an id-based equals.
Test code, from B_MappingTest.java:
Set<SetItem> set = new HashSet<>();
SetItem item = new SetItem("x");
set.add(item);
p("before persist: id=%s, set.contains(item)=%s", item.id, set.contains(item));
tx(s -> s.persist(item));
p("after persist : id assigned=%s, hashCode changed, set.contains(item)=%s", item.id != null, set.contains(item));
assertFalse(set.contains(item));
Associations and fetching: where N+1 and LazyInitializationException come from
Most Hibernate production incidents are fetching incidents. The counts below are statements recorded by a Hibernate StatementInspector, not estimates.
Q17. What are the default fetch types?
Read straight from the annotation definitions: @ManyToOne and @OneToOne are EAGER by default; @OneToMany, @ManyToMany and @ElementCollection are LAZY. The eager defaults on the “to-one” side are the ones that surprise people (question 27).
Test code, from C_AssociationsTest.java:
for (Class<?> a : List.of(Basic.class, ManyToOne.class, OneToOne.class, OneToMany.class, ManyToMany.class, ElementCollection.class)) {
p(" @%-17s -> %s", a.getSimpleName(), a.getMethod("fetch").getDefaultValue());
}
default of fetch() on each JPA annotation, read from the API jar:
@Basic -> EAGER
@ManyToOne -> EAGER
@OneToOne -> EAGER
@OneToMany -> LAZY
@ManyToMany -> LAZY
@ElementCollection -> LAZY
Go deeper:Association mappings: the query countsQ18. What is the owning side of a bidirectional association?
The side withoutmappedBy; it owns the foreign key column. Hibernate writes the FK only from the owning side. Adding an employee to department.employees alone stored null in department_id; setting employee.department stored the id. Keep both sides in sync with a helper method.
Test code, from C_AssociationsTest.java:
Department d = new Department("Ops");
Employee e = new Employee("Ivy", 1, null);
d.employees.add(e); // only the inverse side is set
s.persist(d);
s.persist(e);
employee.department_id after d.employees.add(e) only: null
after setting employee.department too : 1
Go deeper:@OneToMany: bidirectional sync
The left panel is the N+1 problem and the right panel is the usual cure: the same data in one statement. The next question counts both with real SQL.
Q19. What is the N+1 problem and how do you fix it?
One query loads N parents, then touching a lazy association on each parent runs one more query per parent: 1 + N. Fix it by loading the association in the same query (join fetch, an entity graph) or in batches (question 21). For 3 departments the loop ran 4 statements and the join fetch ran 1.
Test code, from C_AssociationsTest.java:
for (Department d : s.createSelectionQuery("from Department", Department.class).getResultList()) {
d.getEmployees().size();
}
for (Department d : s.createSelectionQuery("select distinct d from Department d join fetch d.employees", Department.class).getResultList()) {
d.getEmployees().size();
}
naive loop over 3 departments: 4 SQL statements (1 + 3)
SQL> select d1_0.id,d1_0.name from Department d1_0
SQL> select e1_0.department_id,e1_0.id,e1_0.city,e1_0.zip,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.department_id=?
SQL> select e1_0.department_id,e1_0.id,e1_0.city,e1_0.zip,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.department_id=?
SQL> select e1_0.department_id,e1_0.id,e1_0.city,e1_0.zip,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.department_id=?
with join fetch: 1 SQL statement
SQL> select distinct d1_0.id,e1_0.department_id,e1_0.id,e1_0.city,e1_0.zip,e1_0.level,e1_0.name,e1_0.salary,d1_0.name from Department d1_0 join Employee e1_0 on d1_0.id=e1_0.department_id
Go deeper:Lazy loading: three fetch strategies, benchmarked · N+1 in GraphQL with DataLoaderQ20. JOIN FETCH or an entity graph?
Both load the association in the same statement. A join fetch in the query text is part of that query and here produced an inner join, which drops rows with no match. An entity graph is applied from outside the query and used a left join in this test. Choose the graph when the same query needs different fetch plans for different callers.
Test code, from C_AssociationsTest.java:
List<Employee> list = s.createSelectionQuery("select e from Employee e join fetch e.department", Employee.class).getResultList();
p("join fetch : department initialized = %s", Hibernate.isInitialized(list.get(0).getDepartment()));
EntityGraph<Employee> g = s.createEntityGraph(Employee.class);
g.addAttributeNodes("department");
List<Employee> list = s.createSelectionQuery("from Employee", Employee.class).setEntityGraph(g, GraphSemantic.FETCH).getResultList();
p("entity graph : department initialized = %s", Hibernate.isInitialized(list.get(0).getDepartment()));
join fetch : department initialized = true
SQL> select e1_0.id,e1_0.city,e1_0.zip,d1_0.id,d1_0.name,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 join Department d1_0 on d1_0.id=e1_0.department_id
entity graph : department initialized = true
SQL> select e1_0.id,e1_0.city,e1_0.zip,d1_0.id,d1_0.name,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 left join Department d1_0 on d1_0.id=e1_0.department_id
Go deeper:Lazy loading in Hibernate 7Q21. What does @BatchSize / default_batch_fetch_size do?
It keeps associations lazy but loads them for several parents at once: with a batch size of 2, three departments needed one statement for the parents, one department_id = any (?) for two of them, and one for the last. That turns 1 + N into 1 + ceil(N / size) without changing any query.
Test code, from C_AssociationsTest.java:
default_batch_fetch_size=2, 3 departments: 3 SQL statements (1 + ceil(3/2))
SQL> select d1_0.id,d1_0.name from Department d1_0
SQL> select e1_0.department_id,e1_0.id,e1_0.city,e1_0.zip,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.department_id = any (?)
SQL> select e1_0.department_id,e1_0.id,e1_0.city,e1_0.zip,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.department_id=?
Go deeper:Lazy loading in Hibernate 7Q22. What causes LazyInitializationException, and how do you cure it?
Reading a lazy association after the session that could load it has closed. The message names the role and says “no session”. The cures are to load what you need inside the transaction (a fetch in the query), or to ask for exactly the columns you need. Keeping a session open for the whole web request hides the problem rather than fixing it.
Test code, from C_AssociationsTest.java:
Department d = txr(s -> s.createSelectionQuery("from Department order by id", Department.class).getResultList().get(0));
try {
d.getEmployees().size();
Department fresh = s.createSelectionQuery("select d from Department d join fetch d.employees where d.id = :id", Department.class).setParameter("id", d.getId()).getSingleResult();
return fresh.getEmployees().size();
read a lazy collection after the session closed -> org.hibernate.LazyInitializationException: Cannot lazily initialize collection of role 'lab.hibernate.model.Department.employees' with key '1' (no session)
cure 1: fetch it inside the query -> 3 employees, 1 SQL statement
cure 2: ask for exactly the columns you need -> [E11, E12, E13], 1 SQL statement
Go deeper:Proxies and LazyInitializationExceptionQ23. What is MultipleBagFetchException?
Fetching two List collections (bags) of the same entity in one query would multiply rows into a Cartesian product, and Hibernate refuses. In 7.4.13 it arrives wrapped in an IllegalArgumentException. Fetch one collection per query (the second query reuses the managed parent), or model one of them as a Set.
Test code, from C_AssociationsTest.java:
tx(s -> s.createSelectionQuery("select p from Post p join fetch p.comments join fetch p.tags", Post.class).getResultList());
Post post = s.createSelectionQuery("select p from Post p join fetch p.comments", Post.class).getSingleResult();
s.createSelectionQuery("select p from Post p join fetch p.tags where p in :p", Post.class).setParameter("p", post).getSingleResult();
two List collections in one join fetch -> java.lang.IllegalArgumentException: org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags: [lab.hibernate.model.Post.comments, lab.hibernate.model.Post.tags]
fix: one collection per query -> comments=2 tags=2, 2 SQL statements
Go deeper:@OneToMany: Set vs ListQ24. Can you paginate a query that join-fetches a collection?
The old answer is “no: Hibernate loads everything and pages in memory” (warning HHH90003004 “firstResult/maxResults specified with collection fetch; applying in memory”). On 7.4.13 that is not what happened: the SQL below pages a derived table of parents inside the statement (offset ? rows fetch first ? rows only) and joins the children to it, and that warning did not appear in the log of this run. The two-step id query still works and is shown for comparison.
Test code, from C_AssociationsTest.java:
List<Department> page = s.createSelectionQuery("select d from Department d join fetch d.employees order by d.id", Department.class).setMaxResults(2).getResultList();
p("join fetch of a collection + setMaxResults(2) -> %d departments", page.size());
join fetch of a collection + setMaxResults(2) -> 2 departments
SQL contains a row limit: true
SQL> select d1_0.id,e1_0.department_id,e1_0.id,e1_0.city,e1_0.zip,e1_0.level,e1_0.name,e1_0.salary,d1_0.name from (select d1_0.id,d1_0.name from Department d1_0 where exists(select 1 from Employee e1_0 where d1_0.id=e1_0.department_id) order by d1_0.id offset ? rows fetch first ? rows only) d1_0(id,name) join Employee e1_0 on d1_0.id=e1_0.department_id order by d1_0.id
two-step fix -> 2 departments
SQL> select d1_0.id from Department d1_0 order by d1_0.id fetch first ? rows only
SQL> select distinct d1_0.id,e1_0.department_id,e1_0.id,e1_0.city,e1_0.zip,e1_0.level,e1_0.name,e1_0.salary,d1_0.name from Department d1_0 join Employee e1_0 on d1_0.id=e1_0.department_id where d1_0.id in (?,?) order by d1_0.id
Go deeper:Pagination in Hibernate 7: the JOIN FETCH trapQ25. What does cascade do, and what is a TransientPropertyValueException?
Cascade propagates an operation from a parent to its associated entities. Without it, persisting an Employee that points at an unsaved Department fails at flush with TransientPropertyValueException (wrapped in IllegalStateException here). With CascadeType.ALL on Post.comments, persisting the post saved its comment too.
Test code, from C_AssociationsTest.java:
tx(s -> s.persist(new Employee("Jo", 1, new Department("Unsaved"))));
Post post = new Post("with cascade");
post.getComments().add(new Comment("c", post));
s.persist(post); // CascadeType.ALL on Post.comments saves the comment too
persist Employee that points at an unsaved Department -> java.lang.IllegalStateException: org.hibernate.TransientPropertyValueException: Persistent instance of 'lab.hibernate.model.Employee' references an unsaved transient instance of 'lab.hibernate.model.Department' (persist the transient instance before flushing) [lab.hibernate.model.Employee.department -> lab.hibernate.model.Department]
Post -> comments has CascadeType.ALL: comments stored = 1
Go deeper:JPA cascade types: a decision treeQ26. orphanRemoval vs CascadeType.REMOVEorphanRemoval = true deletes a child that is removed from the parent’s collection, without any remove() call: the first block below issues a DELETE just from lines.remove(0). Cascading REMOVE deletes children when the parent is deleted: the second block deletes the lines first, then the order.
Test code, from C_AssociationsTest.java:
o.getLines().remove(0); // no remove() call on the OrderLine
tx(s -> s.remove(s.find(PurchaseOrder.class, id)));
removed a line from the collection only:
SQL> select po1_0.id,po1_0.customer from PurchaseOrder po1_0 where po1_0.id=?
SQL> select l1_0.order_id,l1_0.id,l1_0.sku from OrderLine l1_0 where l1_0.order_id=?
SQL> delete from OrderLine where id=?
removed the order (cascade ALL):
SQL> select po1_0.id,po1_0.customer from PurchaseOrder po1_0 where po1_0.id=?
SQL> select l1_0.order_id,l1_0.id,l1_0.sku from OrderLine l1_0 where l1_0.order_id=?
SQL> delete from OrderLine where id=?
SQL> delete from PurchaseOrder where id=?
Go deeper:Deleting entities: why your cascade is wrongQ27. Why is the default @ManyToOne EAGER a problem?
Because it loads the association even when you did not ask. A single HQL query for nine employees triggered one extra SELECT per distinct department (4 statements in total); the same query on the mapping with fetch = LAZY ran 1. Eager fetching defined on the mapping cannot be switched off per query, so set associations LAZY and fetch explicitly.
Test code, from C_AssociationsTest.java:
'from EagerEmployee' with the default @ManyToOne: 4 SQL statements for 9 employees in 3 departments
SQL> select ee1_0.id,ee1_0.department_id,ee1_0.name from EagerEmployee ee1_0
SQL> select d1_0.id,d1_0.name from Department d1_0 where d1_0.id=?
SQL> select d1_0.id,d1_0.name from Department d1_0 where d1_0.id=?
SQL> select d1_0.id,d1_0.name from Department d1_0 where d1_0.id=?
'from Employee' with @ManyToOne(fetch = LAZY): 1 SQL statement
Caching: first-level, second-level and the stale-read trap
The first-level cache was question 8. This section is the optional layer behind it. The second-level cache in these tests is Ehcache 3 behind Hibernate’s JCache integration with @Cache(usage = READ_WRITE) on the entity.
Q28. How does the second-level cache work?
It is shared by all sessions of one SessionFactory and must be enabled and given a provider; entities opt in. A read checks the session, then the L2 region, then the database. In the output, persist already put the row into L2; after evicting all regions, three finds in three sessions produced one SELECT, one miss, two hits.
Test code, from D_CachingTest.java:
private static final String[] L2 = {
"hibernate.cache.use_second_level_cache", "true",
"hibernate.cache.use_query_cache", "true",
"hibernate.cache.region.factory_class", "jcache",
"hibernate.javax.cache.provider", "org.ehcache.jsr107.EhcacheCachingProvider",
"hibernate.javax.cache.missing_cache_strategy", "create"
};
sf.getCache().evictAllRegions();
st.clear();
int m = SqlLog.mark();
tx(s -> s.find(Product.class, id));
tx(s -> s.find(Product.class, id));
tx(s -> s.find(Product.class, id));
p("after evictAllRegions(), three find() calls, three different sessions:");
right after persist() the row is already in L2 (READ_WRITE caches on insert): SQL selects = 0, hits = 1
after evictAllRegions(), three find() calls, three different sessions:
SQL selects : 1
L2 cache hits : 2
L2 cache misses : 1
L2 cache puts : 1
Go deeper:Second-level cache: when to turn it on · Ehcache 3 claims that do not holdQ29. What is the query cache, and what invalidates it?
It caches the result ids of a query marked cacheable, keyed by query and parameters. The second run was served from the cache (hits go from 0 to 1, no new SELECT). Inserting a row into the queried table invalidated it, so the third run missed and went to the database again.
Test code, from D_CachingTest.java:
Runnable run = () -> tx(s -> s.createSelectionQuery("from Product p where p.price > 5", Product.class).setCacheable(true).getResultList());
int m = SqlLog.mark();
run.run();
p("run 1: query cache hits=%d misses=%d puts=%d", st.getQueryCacheHitCount(), st.getQueryCacheMissCount(), st.getQueryCachePutCount());
run.run();
p("run 2: query cache hits=%d misses=%d puts=%d", st.getQueryCacheHitCount(), st.getQueryCacheMissCount(), st.getQueryCachePutCount());
p("SQL selects for Product after 2 runs: %d", SqlLog.countSince(m, "select"));
tx(s -> s.persist(new Product("Ink", 20))); // any write to the table invalidates cached results
run.run();
p("run 3 after inserting a Product: query cache hits=%d misses=%d", st.getQueryCacheHitCount(), st.getQueryCacheMissCount());
run 1: query cache hits=0 misses=1 puts=1
run 2: query cache hits=1 misses=1 puts=1
SQL selects for Product after 2 runs: 1
run 3 after inserting a Product: query cache hits=1 misses=2
SQL selects for Product after 3 runs: 3
Go deeper:Second-level cacheQ30. Do bulk updates and native SQL keep the cache correct?
It depends on who ran the SQL. An HQL bulk update and a native executeUpdate both caused the next find to miss the cache and read the new price. A raw JDBC update through the connection bypassed Hibernate entirely: the next find was a cache hit returning the old price. If anything else writes to your tables, the L2 cache can be stale.
Test code, from D_CachingTest.java:
tx(s -> s.createMutationQuery("update Product set price = 11").executeUpdate());
st.clear();
int priceAfterHql = txr(s -> s.find(Product.class, id).getPrice());
p("after an HQL bulk update to 11 : find() sees %d (L2 hits=%d misses=%d)", priceAfterHql, st.getSecondLevelCacheHitCount(), st.getSecondLevelCacheMissCount());
tx(s -> s.createNativeMutationQuery("update product set price = 12").executeUpdate());
st.clear();
int priceAfterNative = txr(s -> s.find(Product.class, id).getPrice());
p("after a native bulk update to 12 : find() sees %d (L2 hits=%d misses=%d)", priceAfterNative, st.getSecondLevelCacheHitCount(), st.getSecondLevelCacheMissCount());
assertEquals(11, priceAfterHql);
tx(s -> s.doWork(c -> {
try (var stmt = c.createStatement()) {
stmt.executeUpdate("update product set price = 13");
}
}));
st.clear();
int priceAfterJdbc = txr(s -> s.find(Product.class, id).getPrice());
p("after raw JDBC update to 13 : find() sees %d (L2 hits=%d misses=%d) <- stale: Hibernate was not told", priceAfterJdbc, st.getSecondLevelCacheHitCount(), st.getSecondLevelCacheMissCount());
assertEquals(12, priceAfterJdbc);
after an HQL bulk update to 11 : find() sees 11 (L2 hits=0 misses=1)
after a native bulk update to 12 : find() sees 12 (L2 hits=0 misses=1)
after raw JDBC update to 13 : find() sees 12 (L2 hits=1 misses=0) <- stale: Hibernate was not told
Transactions and locking: lost updates, versions and isolation
Two sessions interleaved on one thread (or two threads, for the pessimistic test) against PostgreSQL, whose default isolation level is READ COMMITTED.
Q33. What is a lost update?
Two transactions read the same row, both compute a new value from what they read, and the second to commit overwrites the first without noticing. With no @Version, Hibernate’s UPDATE only filters on the id. Two deposits of 50 and 25 on a balance of 100 left 125, not 175.
Test code, from E_LockingTest.java:
PlainAccount fromA = a.find(PlainAccount.class, id);
PlainAccount fromB = b.find(PlainAccount.class, id);
fromA.balance += 50; // deposit 50
ta.commit();
fromB.balance += 25; // deposit 25, computed from the stale 100
int m = SqlLog.mark();
tb.commit();
SQL> update PlainAccount set balance=?,owner=? where id=?
two deposits (+50, +25) on 100 -> final balance 125, expected 175: A's deposit silently lost
Go deeper:@Transactional: propagation, isolation, silent failures
The diagram is the fix for the lost update above: the version number is part of the UPDATE’s WHERE clause. A stale writer changes zero rows and Hibernate turns that into an exception.
Q31. How does optimistic locking work?
Add a @Version column. Hibernate increments it on every update and adds and version=? to the WHERE clause. Session B’s stale write matched no row and Hibernate threw jakarta.persistence.OptimisticLockException with a StaleObjectStateException cause; the final balance kept A’s change.
Test code, from E_LockingTest.java:
Account fromA = a.find(Account.class, id);
Account fromB = b.find(Account.class, id);
p("both sessions read balance=%d version=%d", fromA.getBalance(), fromA.getVersion());
fromA.balance += 50;
ta.commit();
p("session A commits +50 -> version is now %d", fromA.getVersion());
fromB.balance += 25;
int m = SqlLog.mark();
try {
tb.commit();
} catch (RuntimeException ex) {
p("session B commits +25 -> %s", first(ex));
Throwable c = ex.getCause();
if (c != null) {
p(" cause: %s", first(c));
}
tb.rollback();
both sessions read balance=100 version=0
session A commits +50 -> version is now 1
session B commits +25 -> jakarta.persistence.OptimisticLockException: Unexpected row count (expected row count 1 but was 0) [update Account set balance=?,owner=?,version=? where id=? and version=?] for entity [lab.hibernate.model.Account with id '1']
cause: org.hibernate.StaleObjectStateException: Unexpected row count (expected row count 1 but was 0) [update Account set balance=?,owner=?,version=? where id=? and version=?] for entity [lab.hibernate.model.Account with id '1']
SQL> update Account set balance=?,owner=?,version=? where id=? and version=?
final balance: 150 (B's stale write was rejected, A's +50 kept)
Go deeper:@Transactional and isolationQ32. How does pessimistic locking work?LockModeType.PESSIMISTIC_WRITE makes the SELECT itself lock the row: on PostgreSQL the SQL ends in for no key update of a1_0. A second session asking with jakarta.persistence.lock.timeout = 0 got nowait added to its SQL and a PessimisticLockException immediately instead of waiting.
Test code, from E_LockingTest.java:
session 1: find(..., PESSIMISTIC_WRITE) ran: select a1_0.id,a1_0.balance,a1_0.owner,a1_0.version from Account a1_0 where a1_0.id=? for no key update of a1_0
session 2 with lock.timeout=0 -> jakarta.persistence.PessimisticLockException: JDBC exception executing SQL [ERROR: could not obtain lock on row in relation "account"] [select a1_0.id,a1_0.balance,a1_0.owner,a1_0.version from Account a1_0 where a1_0.id=? for no key update of a1_0 nowait]
cause: org.hibernate.exception.LockTimeoutException: JDBC exception executing SQL [ERROR: could not obtain lock on row in relation "account"] [select a1_0.id,a1_0.balance,a1_0.owner,a1_0.version from Account a1_0 where a1_0.id=? for no key update of a1_0 nowait]
Go deeper:@Transactional and isolationQ34. What do isolation levels change?
What a transaction can see of other transactions’ commits. PostgreSQL defaults to READ COMMITTED: a second read in the same transaction saw the other transaction’s committed 150 (a non-repeatable read). Under REPEATABLE READ the second read returned the first read’s snapshot.
Test code, from E_LockingTest.java:
for (String level : new String[] {"read committed", "repeatable read"}) {
tx(s -> s.createNativeMutationQuery("update plainaccount set balance = 100").executeUpdate());
try (Session reader = sf.openSession()) {
Transaction t = reader.beginTransaction();
reader.createNativeMutationQuery("set transaction isolation level " + level).executeUpdate();
long first = reader.createNativeQuery("select balance from plainaccount where id = :id", Long.class).setParameter("id", id).getSingleResult();
tx(s -> s.createNativeMutationQuery("update plainaccount set balance = 150").executeUpdate());
long second = reader.createNativeQuery("select balance from plainaccount where id = :id", Long.class).setParameter("id", id).getSingleResult();
p("%-16s: first read %d, another transaction commits 150, second read %d -> %s", level, first, second, first == second ? "repeatable" : "non-repeatable read");
PostgreSQL default isolation level: read committed
read committed : first read 100, another transaction commits 150, second read 150 -> non-repeatable read
repeatable read : first read 100, another transaction commits 150, second read 100 -> repeatable
Go deeper:@Transactional: propagation and isolationQ35. When does a collection change bump the parent’s @Version?
When the collection belongs to the parent. Adding a Coin to a unidirectional @OneToMany (owned through a join table) bumped the Wallet version from 0 to 1. Adding a Doc to a mappedBy (inverse) collection left the Folder version at 0, because the foreign key lives on the child.
Test code, from E_LockingTest.java:
tx(s -> s.find(Wallet.class, wid).getCoins().add(new Coin(25)));
Folder f = s.find(Folder.class, fid);
Doc d = new Doc("a.txt", f);
f.getDocs().add(d);
Wallet (owns a unidirectional @OneToMany): add a Coin -> version 0 -> 1
Folder (inverse, mappedBy @OneToMany) : add a Doc -> version 0 -> 0
Go deeper:@OneToMany done rightQ36. What does @DynamicUpdate change?
By default Hibernate caches one UPDATE statement per entity and sets every column. @DynamicUpdate builds the statement from the changed columns only. The output shows set a=?,b=?,c=? against set a=?. The trade-off is more distinct statements for the database and the JDBC layer to prepare.
Test code, from E_LockingTest.java:
default entity, change one column:
SQL> select p1_0.id,p1_0.a,p1_0.b,p1_0.c from Plain p1_0 where p1_0.id=?
SQL> update Plain set a=?,b=?,c=? where id=?
@DynamicUpdate entity, change one column:
SQL> select w1_0.id,w1_0.a,w1_0.b,w1_0.c from Wide w1_0 where w1_0.id=?
SQL> update Wide set a=? where id=?
Querying: HQL, Criteria, projections and bulk updates
Q37. HQL, Criteria or native SQL?
HQL and the Criteria API are two ways to write the same query against the entity model; in this test they produced identical SQL. Native SQL goes to the database as written and returns whatever you map it to. Use HQL by default, Criteria when the query is built dynamically, and native SQL for features HQL does not model.
Test code, from F_QueryingTest.java:
List<Employee> hql = s.createSelectionQuery("from Employee e where e.salary > :min order by e.salary", Employee.class).setParameter("min", 150).getResultList();
p("HQL -> %s", hql.stream().map(Employee::getName).toList());
CriteriaBuilder cb = sf.getCriteriaBuilder();
CriteriaQuery<Employee> cq = cb.createQuery(Employee.class);
Root<Employee> root = cq.from(Employee.class);
cq.select(root).where(cb.gt(root.get("salary"), 150)).orderBy(cb.asc(root.get("salary")));
List<Employee> crit = s.createSelectionQuery(cq).getResultList();
p("Criteria -> %s", crit.stream().map(Employee::getName).toList());
List<Employee> nat = s.createNativeQuery("select * from employee where salary > 150 order by salary", Employee.class).getResultList();
p("Native -> %s", nat.stream().map(Employee::getName).toList());
HQL -> [Cy, Ana]
Criteria -> [Cy, Ana]
Native -> [Cy, Ana]
SQL> select e1_0.id,e1_0.city,e1_0.zip,e1_0.department_id,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.salary>? order by e1_0.salary
SQL> select e1_0.id,e1_0.city,e1_0.zip,e1_0.department_id,e1_0.level,e1_0.name,e1_0.salary from Employee e1_0 where e1_0.salary>? order by e1_0.salary
SQL> select * from employee where salary > 150 order by salary
Go deeper:HQL in Hibernate 7 · Criteria queries in Hibernate 7Q38. Why project into a DTO instead of loading entities?
A projection selects only the columns you ask for and loads no entities: the SQL below has two columns and the statistics report 0 entities loaded, against 3 for the entity query. A Java record works as the result type.
Test code, from F_QueryingTest.java:
List<EmployeeRow> rows = s.createSelectionQuery("select e.name, e.salary from Employee e order by e.salary", EmployeeRow.class).getResultList();
p("record projection -> %s", rows);
record projection -> [EmployeeRow[name=Bo, salary=100], EmployeeRow[name=Cy, salary=200], EmployeeRow[name=Ana, salary=300]]
entities loaded: 0
SQL> select e1_0.name,e1_0.salary from Employee e1_0 order by e1_0.salary
full entity query loads 3 entities
Go deeper:HQL in Hibernate 7Q39. Does a bulk HQL update change entities already in the session?
No. The statement runs in the database and the session is not told which managed entities it touched. The entity already loaded still said salary 100 after the update changed three rows to 999; refresh brought it up to date.
Test code, from F_QueryingTest.java:
Employee e = s.createSelectionQuery("from Employee where name = 'Bo'", Employee.class).getSingleResult();
int n = s.createMutationQuery("update Employee set salary = 999").executeUpdate();
p("HQL bulk update touched %d rows", n);
p("the entity already loaded still says salary = %d", e.salary);
s.refresh(e);
p("after refresh() it says salary = %d", e.salary);
assertEquals(999, e.salary);
HQL bulk update touched 3 rows
the entity already loaded still says salary = 100
after refresh() it says salary = 999
Go deeper:Deleting entitiesQ40. When is a @NamedQuery checked?
At startup. A typo in the query (titel instead of title) stopped buildSessionFactory() with a NamedQueryValidationException that names the query and the attribute, instead of failing the first time a user hits that code path.
Test code, from F_QueryingTest.java:
@NamedQuery(name = "BrokenQuery.byTitle", query = "select b from BrokenQuery b where b.titel = :t")
public class BrokenQuery {
boot(BrokenQuery.class);
buildSessionFactory() failed with org.hibernate.query.NamedQueryValidationException:
Errors in named queries:
[1] Error in query named 'BrokenQuery.byTitle': Could not resolve attribute 'titel' of 'lab.hibernate.model.BrokenQuery' [select b from BrokenQuery b where b.titel = :t]
1. save() and update() do not exist any more (question 4). Reflection over org.hibernate.Session in 7.4.13 found no save, update, saveOrUpdate or delete.
2. Collection fetch plus pagination is not paged in memory on 7.4.13 (question 24). The SQL pages inside a derived table. Answers that cite the in-memory warning describe older behaviour; I confirmed the SQL and the absence of the warning in this run, not the exact release that changed it.
3. A proxy does not intercept field reads (question 5). Entities with public fields read null from an uninitialised proxy with no error and no SQL.
4. Strictly, “the second-level cache goes stale after bulk SQL” depends on the path (question 30). HQL and Hibernate native updates invalidated the region; only a raw JDBC update left it stale.
The fourth item is why the cache answer is worth giving in two parts in an interview: Hibernate knows about the SQL it runs, and nothing else.
Should you memorise these 40 answers?
No — memorise the mechanisms, then be able to demonstrate them. The question that separates candidates is the follow-up (“and what does that look like in the SQL?”), and every answer above has the SQL or the exception text next to it for that reason. Also check which Hibernate version the interviewer has in mind: three of these answers differ between Hibernate 5 and 7. If your team writes mostly Spring Data repositories, Spring Data JDBC vs JPA is the honest answer to “do we need all of this?” for simple aggregates.
No Comments yet!