The N+1 problem and its fixes, @Entity, cascading, composite keys, SQL injection safety, lazy loading, concurrency and optimistic locking, plus five real scenarios — slow relationship fetching, session handling in web apps, rollback, auditing with Envers and mapping legacy schemas.
Published September 25, 2026
The N+1 problem, lazy loading and optimistic locking are the Hibernate topics that come up in real production incidents, which is why interviewers love them. The scenario questions at the end test judgement. Answer with the specific feature you'd use, and why.
Short answer: You run 1 query to load N parent rows. Then, as your code touches a lazy association on each parent, Hibernate runs N more queries, one per parent. It's invisible in the code, and it gets devastating as N grows.
List<Order> orders = orderRepository.findAll(); // 1 query
for (Order o : orders) {
System.out.println(o.getCustomer().getName()); // +1 query per order → N+1
}
Fixes:
select o from Order o join fetch o.customer.@EntityGraph(attributePaths = "customer") on a Spring Data method.@BatchSize(size = 50), or hibernate.default_batch_fetch_size. This loads the associations in groups with IN (…).select new com.shop.OrderSummary(o.id, c.name) …).Key points to cover:
org.hibernate.SQL=DEBUG), enabling Hibernate statistics, or using tools like the Hypersistence Optimizer. Watch the query counts in tests.Learn it in depth → N+1 Query Problem
@Entity do?Short answer: It marks a class as a JPA entity, meaning its instances map to rows of a table and can be persisted. An entity needs:
@Id;protected);final class, so that proxies can extend it.@Entity
@Table(name = "orders")
public class Order {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
@Column(nullable = false) private String status;
@ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "customer_id") private Customer customer;
@Version private int version; // optimistic locking
protected Order() { } // for JPA
}
Short answer: Cascading propagates entity operations from a parent to its associated children. CascadeType.PERSIST, MERGE, REMOVE, REFRESH, DETACH, or ALL. For example, saving an Order also saves its new OrderLines.
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderLine> lines = new ArrayList<>();
Key points to cover:
REMOVE across shared associations such as @ManyToOne, or deleting one order could delete a customer.orphanRemoval = true deletes a child that's removed from the collection.Short answer: A primary key made of several columns, for example (order_id, line_no). Map it with an @Embeddable key class used as an @EmbeddedId (preferred), or with @IdClass. The key class must implement equals/hashCode, and be Serializable.
@Embeddable
public record OrderLineId(Long orderId, Integer lineNo) implements Serializable { }
@Entity
public class OrderLine {
@EmbeddedId private OrderLineId id;
@MapsId("orderId") @ManyToOne(fetch = FetchType.LAZY) private Order order;
private int quantity;
}
Key points to cover:
Short answer: When you use parameter binding, values are sent as PreparedStatement parameters, never concatenated into the SQL text. This applies to JPQL/HQL named or positional parameters, the Criteria API, and Spring Data derived queries. It doesn't protect you if you build query strings by concatenating user input, whether in HQL or native SQL.
// ❌ vulnerable: user input becomes part of the query text
em.createQuery("from User u where u.email = '" + email + "'");
// ✅ safe: bound parameter
em.createQuery("from User u where u.email = :email", User.class).setParameter("email", email);
Key points to cover:
Short answer: Deferring the loading of an association until it's first accessed. Hibernate injects a proxy, or an uninitialised collection wrapper, and runs the query on first use. It's the default for @OneToMany and @ManyToMany, and it's recommended for @ManyToOne too (which defaults to EAGER).
Common trap: a LazyInitializationException ("could not initialize proxy – no Session") when a lazy association is touched after the transaction or persistence context has closed. That happens, for example, in a controller, or while Jackson serialises an entity. Fix it by fetching what you need inside the service transaction (fetch join or entity graph), and returning DTOs. Don't make everything EAGER.
Short answer: With locking:
@Version): no database locks. It detects conflicting updates at commit time.@Lock(LockModeType.PESSIMISTIC_WRITE) → SELECT … FOR UPDATE): locks the rows while you work, for heavily contended data such as stock counters or seat bookings.Combine them with the right transaction isolation level.
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select s from Stock s where s.sku = :sku")
Stock lockBySku(@Param("sku") String sku);
Learn it in depth → Transactions & Isolation Levels
Short answer: Each row carries a version column (@Version). An update includes the version it read in its condition: UPDATE … SET …, version = 6 WHERE id = ? AND version = 5. If another transaction changed the row in the meantime, zero rows are updated, and Hibernate throws an OptimisticLockException (Spring translates it to ObjectOptimisticLockingFailureException).
Key points to cover:
Short answer:
@EntityGraph, or batch fetching, which fixes N+1.Common trap: answering only "use lazy loading". Lazy loading alone is often what causes the N+1 storm. The real fix is to fetch each use case's data deliberately.
Short answer: Let the framework own them. In Spring, the EntityManager is bound to the transaction (@Transactional on the service layer), and it's opened and closed automatically, even when exceptions occur. In plain Hibernate, use the session-per-request pattern: open the session in a servlet filter, and close it in finally, or use getCurrentSession() with thread-bound context management.
Key points to cover:
spring.jpa.open-in-view=true, with a startup warning). It keeps the session open through view rendering, which hides lazy-loading problems, and holds database connections longer. Most teams set it to false, and fetch what they need in the service layer.Short answer: Everything happened inside one transaction, so the failure triggers a rollback. The database discards every change made in that transaction, and no partial update remains. In Spring, @Transactional rolls back automatically on unchecked exceptions and Errors.
Common trap: a checked exception doesn't roll back by default in Spring. Use @Transactional(rollbackFor = Exception.class) if checked exceptions should roll back. Also, catching the exception inside the method and swallowing it means the transaction commits.
Learn it in depth → @Transactional Deep Dive
Short answer: Hibernate Envers. Annotate entities with @Audited. For every change, Envers writes a revision into _AUD tables, recording the revision number, the type of change (add, modify or delete) and the entity's state. AuditReader then queries the history ("what did this order look like last Tuesday?").
Key points to cover:
@RevisionEntity can record who made the change (the current user) and why.@CreatedDate, @LastModifiedBy, plus @EnableJpaAuditing.Short answer: Map the names explicitly, without changing the schema: @Table(name = "TBL_CUST_MSTR") and @Column(name = "CUST_NM"). Use @JoinColumn for foreign keys, and @Convert / AttributeConverter for odd encodings (for example 'Y'/'N' flags to boolean). For systematic rules, implement a PhysicalNamingStrategy.
@Entity
@Table(name = "TBL_CUST_MSTR")
public class Customer {
@Id @Column(name = "CUST_ID") private Long id;
@Column(name = "CUST_NM") private String name;
@Convert(converter = YesNoConverter.class) @Column(name = "ACTV_FLG") private boolean active; // 'Y'/'N'
}
Key points to cover:
spring.jpa.hibernate.ddl-auto=validate (or none), so Hibernate never tries to alter the legacy schema.Q: What does spring.jpa.hibernate.ddl-auto do, and what should production use?
A: It controls schema generation: create, create-drop, update, validate or none. Production should use validate or none, with schema changes managed by migration tools such as Flyway or Liquibase.
Q: How do you see the SQL that Hibernate generates?
A: Set logging.level.org.hibernate.SQL=DEBUG, plus org.hibernate.orm.jdbc.bind=TRACE for the parameter values. spring.jpa.show-sql=true also works, but it prints to stdout, not through your logger.
Q: What is @Transactional(readOnly = true) good for?
A: It tells Hibernate to skip dirty checking and flushing, and some drivers or databases route such transactions to replicas. Use it on query methods.
Q: Why implement equals/hashCode carefully in entities?
A: IDs are often generated only on persist, so an ID-based hashCode changes after saving, which breaks HashSet membership. Use a business key, or a constant hashCode with ID-based equals (null-safe) for entities.