Cheat SheetsInterview Q&AHibernate & JPA

Hibernate & JPA — Cheat Sheet

Interview Q&A · 100 topics. Download the PDF or the Instagram carousel and share it.

Cheat Sheet · AiCanCode.org
Hibernate & JPA
Interview Q&A100 topicsQuick revision reference
1

What is the difference between JPA and Hibernate?

JPA is a specification — a set of interfaces and annotations defining how object-relational mapping should work in Java. It is not an implementation and you cannot run it on its own. Hibernate is an implementation of that specification, and the most widely used one. EclipseLink and OpenJPA are alternatives. The practical consequence is that coding against JPA interfaces — EntityManager, Query, the jakarta.persistence annotations — keeps you portable, while using Hibernate-specific features such as @Type, the Session API, or Hibernate's multi-tenancy support ties you to Hibernate. In reality almost nobody switches implementations, so the portability argument is weaker than it sounds. The stronger reason to prefer JPA APIs is that they are better documented and more widely understood by the next person reading your code. Hibernate predates JPA and contributed heavily to its design, which is why the two are similar. Hibernate also offers capabilities JPA does not specify — the Criteria API extensions, filters, and richer caching control — and reaching for those deliberately is fine as long as you know you are doing it.

2

What is an entity and what does JPA require of one?

An entity is a class mapped to a database table, whose instances correspond to rows. The requirements: annotate with @Entity, have a no-argument constructor that is at least protected, have an identifier field annotated @Id, and be a top-level non-final class. Fields and methods accessed by the provider must not be final either, because Hibernate subclasses the entity to create proxies. That proxying requirement is the reason for several of these rules and it catches people — making an entity final, or making a getter final, silently breaks lazy loading. Entities should also implement equals and hashCode carefully, and this is the subtle part. Using the generated ID is tempting but wrong for entities not yet persisted, because the ID is null before flush and changes afterwards — so an entity added to a HashSet before persisting becomes unfindable. Using all fields is wrong too, since fields change. The usual advice is a business key if one exists, or a UUID assigned in the constructor. An entity should be a domain object rather than a data bag, though ORM conventions push toward the latter.

3

What are the entity lifecycle states?

Four states, and understanding the transitions explains most Hibernate behaviour. Transient: a newly created object with no database identity and no association with a persistence context. Hibernate knows nothing about it. Managed (or persistent): associated with an open persistence context, which tracks it. Changes to a managed entity are detected automatically and written at flush time — no save call is needed, which surprises people the first time an unintended change is persisted. Detached: was managed, but the persistence context has closed or the entity was evicted. It still has an identity but changes are no longer tracked. Accessing an uninitialised lazy association on a detached entity is what produces LazyInitializationException. Removed: marked for deletion, still in the context, will be deleted at flush. The transitions: persist makes transient managed. find or query returns managed. Closing the context or clear detaches. merge takes a detached instance and returns a managed copy — note it returns a copy rather than making the argument managed, which is a very common mistake.

4

What is the difference between persist, merge, save and saveOrUpdate?

persist is the JPA method for making a transient entity managed. It returns void, and the passed instance becomes managed. It throws if the entity already has an identifier that exists. merge takes a detached (or transient) entity and copies its state onto a managed instance, returning that managed instance. The crucial detail is that the argument does not become managed — the return value does. Code that calls merge and then continues using the original object is a common bug: changes to it are not tracked. save and saveOrUpdate are Hibernate-specific and predate JPA. save returns the generated identifier and may issue the insert immediately to obtain it, which persist is not required to do. saveOrUpdate decides between them based on whether the identifier is set. The practical guidance is to use persist and merge, since they are the standard and their semantics are better defined. The performance note worth raising: merge issues a select to load the current state before copying, so merging an entity you know is new causes an unnecessary query. Use persist when you know it is new.

5

What identifier generation strategies exist and which should you use?

IDENTITY uses the database's auto-increment column. Simple, but it forces Hibernate to execute the insert immediately on persist to obtain the ID, which disables JDBC batch inserts entirely. That is a significant performance limitation for bulk writes. SEQUENCE uses a database sequence. Hibernate can fetch identifiers in advance, so inserts can be batched, and it can allocate a block of IDs at once to reduce round trips. This is the recommended strategy on databases that support sequences. TABLE emulates a sequence with a table. Portable but slow and contended, and rarely the right choice. AUTO lets the provider pick, which historically produced surprising results across databases — on MySQL it once chose TABLE. UUID identifiers assigned in the application avoid a round trip entirely and let you know the ID before insert, which is convenient. The cost is index performance: random UUIDs scatter across the B-tree causing page splits. Time-ordered UUIDs (version 7) largely solve that. The practical recommendation: SEQUENCE with an allocation size matching your batch size on PostgreSQL and Oracle, and consider UUIDv7 if you need client-side generation.

6

What is the difference between @Entity and @Embeddable?

An @Entity has its own identity and its own table row — it can be queried, referenced and has an independent lifecycle. An @Embeddable has no identity of its own. Its fields are stored as columns in the owning entity's table, and it exists only as part of that entity. The modelling significance is that @Embeddable is how you express a value object. An Address with street, city and postcode is conceptually a value — two identical addresses are interchangeable — and embedding it keeps the columns in the customer table while giving you a proper type in the model rather than four loose string fields. That directly addresses primitive obsession, and it is underused. Money as an embeddable holding amount and currency prevents the currency being forgotten. The practical details: use @Embedded on the field, and @AttributeOverride when the same embeddable is used twice in one entity and the column names would collide — a billing address and a shipping address, for instance. An embeddable should be immutable where possible, since Hibernate dirty-checks it by value and a shared mutable embeddable can produce surprising updates.

7

What are the inheritance mapping strategies and what are their trade-offs?

SINGLE_TABLE puts the whole hierarchy in one table with a discriminator column. Queries are fast with no joins, and polymorphic queries are trivial. The cost is that subclass-specific columns must be nullable, so the database cannot enforce not-null constraints on them — which loses real data integrity. JOINED gives each class its own table, with subclass tables joined to the parent by the identifier. Properly normalised, constraints work correctly, and no wasted columns. The cost is a join per level on every read, which hurts for deep hierarchies. TABLE_PER_CLASS gives each concrete class a complete table with all inherited columns. No joins for a single-type query, but polymorphic queries require a UNION across every table, which is slow, and identifiers must be unique across tables so IDENTITY generation cannot be used. MAPPED_SUPERCLASS is not really inheritance mapping — the parent is not an entity and cannot be queried polymorphically. It is just shared field definitions. The practical default is SINGLE_TABLE for shallow hierarchies where the nullable columns are acceptable, and JOINED when data integrity matters more than read performance.

8

What is a DTO and why should you not return entities from an API?

A DTO is a plain object shaped for transport, distinct from the entity shaped for persistence. Returning entities directly causes several problems. Serialising a managed entity triggers lazy loading as the serialiser walks associations, producing a cascade of unexpected queries — or a LazyInitializationException if the context has closed. Bidirectional associations cause infinite recursion unless annotated around. It exposes your schema as your API contract, so a column rename becomes a breaking change for clients. It leaks fields you did not intend to publish, and adding a field to an entity silently adds it to the API. And it prevents shaping the response for the consumer — you send whatever the table has, over-fetching everything. The alternative is a DTO per use case, populated either by mapping in code or, better, by a JPQL constructor expression that selects only the needed columns. That last approach avoids loading the entity at all, which is both faster and avoids the lazy loading question entirely. The objection is mapping boilerplate, which MapStruct or records reduce substantially. It is worth the cost at any boundary that outlives a single release.

9

What is the difference between a Spring Data repository and an EntityManager?

EntityManager is the JPA API — the low-level interface for persisting, finding, querying and managing the persistence context. A Spring Data repository is an abstraction on top. You declare an interface and Spring generates the implementation, deriving queries from method names, providing CRUD operations, and handling paging. The repository is far less code for common cases and is the right default. Its limitations show with complex queries: derived method names become unreadable past three conditions, and dynamic queries with optional filters cannot be expressed at all. For those, the options are @Query with JPQL, the Criteria API for genuinely dynamic queries, Specifications which wrap Criteria in a composable form, or dropping to EntityManager in a custom repository fragment. The practical point worth making is that Spring Data hides the persistence context, and that hiding is where confusion comes from. People are surprised that a repository save on a managed entity is redundant, or that modifying an entity inside a transaction persists without any save call at all. Understanding that the EntityManager is underneath explains both. Use repositories, but know what they sit on.

10

What does @Transactional actually do, and what are its common pitfalls?

It wraps a method in a transaction — beginning one if none exists, committing on normal return, rolling back on an unchecked exception. Spring implements it with a proxy, and that mechanism causes the two most common pitfalls. Self-invocation: calling an annotated method from another method of the same class bypasses the proxy entirely, so no transaction starts. The annotation appears to do nothing, and there is no warning. The fix is to move the method to another bean, or inject a self-reference, or use AspectJ weaving. Rollback rules: by default Spring rolls back on RuntimeException and Error but commits on checked exceptions. Code that throws a checked exception expecting a rollback gets a commit instead, which is a genuinely dangerous surprise. Use rollbackFor to change it. Other traps: private methods cannot be proxied so the annotation is ignored; catching an exception inside the transactional method prevents rollback since the proxy never sees it; and marking a method readOnly gives Hibernate a hint to skip dirty checking, which is a real performance win that is frequently omitted. Also: the transaction begins when the proxy is entered, not when the first query runs.

11

What is the difference between JPA and JDBC, and when would you use raw JDBC?

JDBC is the low-level API for executing SQL and reading result sets. JPA is an abstraction that maps objects to rows and manages their lifecycle. JPA buys you dirty checking, lazy loading, caching, identity management within a context, and a portable query language. The cost is that a lot happens implicitly — queries you did not write are executed, and understanding performance requires understanding the machinery. Raw JDBC, or a thin layer like JdbcTemplate or jOOQ, is preferable in several cases. Bulk operations: updating a million rows through entities means loading a million objects. A single SQL update is orders of magnitude faster. Complex reporting queries with aggregations, window functions and CTEs, which JPQL either cannot express or expresses badly. Read-heavy paths where you want exactly the columns you need and no object graph. And anywhere you need database-specific features. The pragmatic architecture is both: JPA for the transactional domain model where its lifecycle management earns its keep, and direct SQL for reporting and bulk work. Insisting on one tool for everything is where most ORM frustration comes from.

12

What is the object-relational impedance mismatch?

The set of structural differences between object models and relational models that an ORM must bridge, and cannot bridge perfectly. Granularity: objects can be finely composed, while tables are flat. An Address value object has no natural table representation without either embedding or a join. Inheritance: relational databases have no inheritance, so any mapping strategy is a compromise, as the three JPA strategies demonstrate. Identity: Java has reference equality and equals; databases have primary keys. Reconciling them is why entity equals and hashCode is genuinely difficult. Associations: object references are directional and can be navigated freely; foreign keys are directional in the other sense, and bidirectional associations must be manually kept consistent. Data navigation: object code walks references one at a time, which produces N+1 queries; SQL wants set-based access. The practical significance is that an ORM hides these differences until it cannot, and every classic Hibernate problem — N+1, LazyInitializationException, surprising updates from dirty checking — is a place where the abstraction leaks. Knowing the mismatch exists is what stops you expecting the ORM to be transparent.

13

What is the persistence context and what does it do for you?

The persistence context is a cache of managed entities scoped to a transaction or an EntityManager — often called the first-level cache. Every managed entity lives in it, keyed by type and identifier. It provides several things. Identity guarantee: finding the same row twice within one context returns the identical Java object, so reference equality holds. Dirty checking: at flush time Hibernate compares each managed entity against a snapshot taken at load and issues updates for what changed — which is why no explicit save is needed. Write-behind: operations are queued and executed at flush, which allows batching and reordering. And repeat-read avoidance: a second find for a loaded entity is served from memory with no query. The consequences that catch people: modifying a managed entity inside a transaction persists the change whether or not you intended it. And the context grows for the life of the transaction, so loading a hundred thousand entities holds all of them in memory plus a snapshot of each — which is a common cause of OutOfMemoryError in batch jobs. Clearing periodically, or avoiding entities entirely for bulk work, is the fix.

14

What is flushing and when does it happen?

Flushing is synchronising the persistence context with the database — Hibernate works out what changed and issues the corresponding insert, update and delete statements. It does not commit. It happens at three points by default. Before a query executes, if the query might be affected by pending changes — so Hibernate keeps your query results consistent with your uncommitted work. Before transaction commit. And when flush is called explicitly. The automatic pre-query flush is the one people do not expect, and it explains why a query in the middle of a method can suddenly issue updates. FlushMode.COMMIT changes this to flush only at commit, which avoids the pre-query flushes and is faster — but queries may then not see your own pending changes, which can be genuinely wrong. The ordering matters too: Hibernate flushes in a fixed order — inserts, then updates, then deletes — regardless of the order you performed the operations. That is why deleting a row and inserting one with the same unique key in the same transaction fails with a constraint violation, which is a confusing and frequently encountered problem. An explicit flush between the two operations is the workaround.

15

What causes LazyInitializationException and how do you fix it properly?

It occurs when you access an uninitialised lazy association after the persistence context has closed. The proxy tries to load and finds no session. The typical cause is loading an entity in a transactional service, returning it, and having the view or serialiser navigate to a lazy collection after the transaction ended. The fixes, in order of preference. Fetch what you need in the query: a JOIN FETCH in JPQL, or an entity graph, so the association is initialised while the context is open. This is explicit and efficient. Project into a DTO in the query, so no entity or proxy is ever returned. This removes the problem entirely and is usually the best answer for read paths. What to avoid: changing the association to EAGER, which fixes this call site and imposes the cost on every other one, and reintroduces N+1 in list queries. And Open Session In View, which keeps the session open for the whole request — it makes the exception disappear while allowing uncontrolled lazy loading during rendering, so query counts become invisible and unpredictable. Spring Boot enables it by default, and turning it off is usually the right call.

16

What is Open Session In View and why is it controversial?

OSIV keeps the Hibernate session open for the entire HTTP request, including view rendering, rather than closing it when the service transaction ends. The benefit is convenience: lazy associations can be navigated during rendering without LazyInitializationException, so developers do not have to think about what to fetch. The criticism is that it makes query behaviour invisible and unbounded. Rendering a list of orders that touches each order's customer issues a query per order, and nothing in the service layer reveals it — the queries happen in the template. Query counts become unpredictable and grow silently as the view changes. It also holds a database connection for the full request duration, including time spent rendering and writing to the network. Under load that exhausts the connection pool far sooner than necessary. And it blurs the layering: the presentation layer now depends on an open persistence context. Spring Boot enables it by default, which is a defensible choice for getting started and a poor one for production. The recommended approach is to disable it and be explicit about fetching — which forces the design conversation that OSIV lets you avoid.

17

What is dirty checking and what are its costs?

At load time Hibernate takes a snapshot of each entity's state. At flush it compares the current state against the snapshot and generates updates for whatever differs. The benefit is that you never write update statements — modifying a managed object is enough, which is a large part of the ORM's appeal. The costs are real. Memory: every managed entity is held twice, as the object and as its snapshot. Loading fifty thousand entities in a transaction doubles the footprint. CPU: the comparison is proportional to the number of managed entities and their fields, and it happens at every flush — including the automatic flushes before queries. A transaction with many entities and many queries does this repeatedly. The mitigations: mark read-only transactions with @Transactional(readOnly = true), which lets Hibernate skip snapshots entirely — a substantial and frequently missed win. Use DTO projections for reads, since projected results are not managed. And clear the context periodically in batch processing. The surprising behaviour worth naming: any change to a managed entity is persisted, even one made accidentally. Code that modifies an entity for a calculation and does not intend to save it will save it anyway.

18

What is the difference between find, getReference and a query?

find returns the entity, loading it from the database if it is not already in the persistence context, and returns null if the row does not exist. getReference returns a proxy without hitting the database. The query is deferred until a property other than the identifier is accessed. If the row does not exist, you get EntityNotFoundException at that later point rather than at the call. The value of getReference is avoiding an unnecessary select when you only need a reference — setting a foreign key relationship, for instance. Assigning order.setCustomer(em.getReference(Customer.class, id)) writes the correct foreign key without loading the customer at all. The hazard is the deferred failure and the fact that the returned object is a proxy — so getClass returns the proxy class, and instanceof and equals can behave unexpectedly if not written carefully. A query always goes to the database, though the entities it returns are put into the persistence context and subsequent finds are served from there. Note that a query does not check the first-level cache before executing — it always issues SQL, then reconciles results with already-managed instances. That last detail surprises people expecting the cache to prevent the query.

19

How do you handle batch processing without exhausting memory?

The problem is that every entity you touch stays managed in the persistence context, with a snapshot, until the transaction ends. Processing a million rows through entities is an OutOfMemoryError. The standard technique is to flush and clear periodically — every few hundred entities, call flush to write pending changes and clear to detach everything. That bounds memory and lets JDBC batching work. Set hibernate.jdbc.batch_size to enable statement batching, and make sure the identifier strategy allows it: IDENTITY prevents insert batching entirely because Hibernate must execute each insert to obtain the key, so use SEQUENCE with a matching allocation size. Also set order_inserts and order_updates so statements group by table, which lets batching actually take effect. For reads, use a scrollable result set or pagination rather than loading everything, and consider StatelessSession, which bypasses the persistence context entirely — no dirty checking, no first-level cache, no cascade — which is exactly what bulk work wants. The honest answer for very large volumes is that JPA is the wrong tool. A single bulk SQL statement, or JdbcTemplate with explicit batching, is far faster and simpler than fighting the ORM.

20

What does cascade do and which cascade types are dangerous?

Cascade propagates an operation from a parent entity to its associated entities — persisting a parent also persists its children, and so on. The types map to the lifecycle operations: PERSIST, MERGE, REMOVE, REFRESH, DETACH, and ALL. Cascade PERSIST and MERGE are usually safe and genuinely convenient for a true parent-child relationship where children have no independent existence. Cascade REMOVE is the dangerous one. Deleting a parent deletes its children, which is correct for composition — deleting an order should delete its line items — and catastrophic for association. Cascading remove from a Post to its Tags would delete tags shared with other posts. The rule is to cascade only where the child genuinely cannot exist without the parent. Cascade ALL on a @ManyToMany is almost always a bug for exactly this reason. The related feature is orphanRemoval, which deletes a child when it is removed from the parent's collection — stronger than cascade REMOVE, since it fires on disassociation rather than only on parent deletion. It expresses true composition and is the right choice for owned children. Cascade REMOVE also loads every child to delete them individually, which is slow for large collections.

21

Why can equals and hashCode be problematic for entities?

Because an entity's identity changes during its lifecycle, and the collections it is placed in do not know that. Using the generated ID: before persist the ID is null, so two new entities are equal to each other, and after persist the hash code changes. An entity added to a HashSet while transient becomes unfindable once it is persisted, because it now hashes to a different bucket. That is a genuinely confusing bug. Using all fields: the hash changes whenever any field changes, with the same bucket problem, and it makes equality depend on mutable state. Using the default identity semantics: works within one persistence context, since the context guarantees one instance per row, but fails across contexts — the same row loaded in two sessions gives two objects that are not equal. The practical recommendations: use a natural business key if a stable immutable one exists. Otherwise assign a UUID in the constructor, so identity exists from creation and never changes. If you must use the generated ID, make hashCode return a constant so bucket placement never changes, and have equals compare IDs with a null check. That degrades HashSet to a list but is correct.

22

What is the difference between the first-level and second-level cache?

The first-level cache is the persistence context. It is mandatory, always on, and scoped to a single EntityManager or transaction. Its purpose is identity — one object per row within a context — and avoiding repeated loads of the same entity. The second-level cache is optional, shared across sessions and typically across the whole application, and configured per entity. Providers include Ehcache, Infinispan and Hazelcast. It caches entity state by identifier, so a find in a fresh session can be served without a query. The key difference is scope and lifetime: the first-level cache dies with the transaction; the second-level cache persists. The second-level cache is worth enabling for reference data that is read constantly and changed rarely — country lists, configuration, product catalogues. It is a poor fit for frequently-modified entities, because invalidation costs and staleness risk outweigh the saving. The complications: in a clustered deployment the cache must be distributed or invalidated across nodes, or different instances serve different data. And direct SQL updates that bypass Hibernate leave the cache stale with no way to know. There is also a query cache, which is separate and needs the second-level cache to be useful.

23

What happens if you modify an entity outside a transaction?

It depends on whether the entity is managed and whether a persistence context is open. If the entity is detached — the usual case outside a transaction — the change is simply a change to a plain Java object. Nothing is tracked and nothing is written. To persist it you must merge it inside a transaction. If a persistence context is open outside a transaction — which happens with Open Session In View, or with an extended context — the entity may still be managed, and the change is recorded in the context. Whether it reaches the database depends on whether a flush occurs, which without a transaction generally does not. So the change is silently lost, which is worse than an error. The practical guidance is to make the boundary explicit: load, modify and save inside a transaction, and treat anything returned to the caller as detached. The related surprise is the opposite case: modifying a managed entity inside a transaction persists the change even without calling save. Code that loads an entity to inspect it, adjusts a field for a calculation, and never intends to store it will store it at commit. Use read-only transactions to prevent that.

24

What is the N+1 problem and how do you detect it?

One query loads a list of N entities, then accessing a lazy association on each triggers one query per entity — N+1 queries where one or two would do. It is the single most common ORM performance problem, and it is insidious because it does not appear in the code. The loop looks like ordinary object navigation; the queries are issued by proxies. It also scales with data, so it passes testing with ten rows and collapses in production with ten thousand. Detection is the important half. Enable SQL logging in development, but more usefully, count queries per request. Tools like Hypersistence Optimizer or a simple statement inspector can assert that an operation issues a bounded number of queries, and that assertion belongs in your test suite — it catches the regression at the moment someone adds an innocuous field access. Datasource-proxy and p6spy can log and count. Spring Boot can log the count per request. The fix depends on the case: JOIN FETCH or an entity graph to load the association in one query, a batch size so Hibernate loads associations in groups rather than one at a time, or a DTO projection that fetches exactly what is needed.

25

What does mappedBy mean and which side owns a relationship?

The owning side is the one whose table holds the foreign key, and it is the side Hibernate reads when deciding what to write. mappedBy marks the inverse side, telling Hibernate that this association is already mapped by a field on the other entity. The consequence is the source of a very common bug: changes to the inverse side are ignored. Adding a child to a parent's collection when the parent is the inverse side writes nothing, because Hibernate looks at the child's reference to the parent to determine the foreign key. So for a bidirectional one-to-many, the many side owns it — the child holds the foreign key — and you must set the child's parent reference for the change to persist. The standard remedy is a helper method on the parent that sets both sides at once: addItem sets the item's order reference and adds it to the collection. That keeps the object graph consistent and ensures the owning side is updated. Forgetting to maintain both sides also breaks in-memory navigation even when persistence works, because the collection is stale until reloaded. Without mappedBy, Hibernate treats the two associations as separate and creates a join table, which is almost never intended.

26

Why is a unidirectional @OneToMany without a join column inefficient?

Because Hibernate has no foreign key on the child pointing back, so by default it creates a join table — an extra table for what should be a simple foreign key. Worse is the update behaviour. Because the association is mapped only from the parent side, Hibernate manages the collection by deleting all rows for that parent and reinserting them whenever the collection changes. Adding one child to a collection of a hundred can produce a hundred deletes and a hundred and one inserts. That behaviour is genuinely surprising and shows up as inexplicable write amplification. The fixes: add @JoinColumn to the one-to-many so Hibernate uses a foreign key on the child table instead of a join table. That removes the extra table but still has weaker update semantics than the alternative. The better fix is to make it bidirectional with the many side owning it, which gives normal foreign key updates — one insert for one new child. Or, if you do not need to navigate from parent to children, drop the collection entirely and query the children by parent ID. A collection you rarely use is a liability, and this is often the cleanest answer.

27

How should you map a @ManyToMany relationship?

The direct answer is a join table with @ManyToMany on both sides and mappedBy on the inverse. But the more useful answer is that you usually should not. A plain @ManyToMany works only when the association carries no data of its own. The moment you need an attribute on the relationship — when a student enrolled, what role a user has in a group — you cannot express it, and you must restructure into two one-to-many relationships around an explicit join entity. Since that requirement appears more often than not, modelling the join as an entity from the start is frequently the better choice. Enrollment as an entity with student, course and enrolledAt is more honest than a bare many-to-many, and promoting it later is a breaking schema change. If you do use @ManyToMany, use a Set rather than a List. With a List, Hibernate deletes all join rows and reinserts them when the collection changes; with a Set it issues targeted inserts and deletes. That difference is significant and is a well-known gotcha. And never cascade REMOVE on a many-to-many, which would delete entities shared with others.

28

What is the difference between @OneToOne with a shared primary key and with a foreign key?

A foreign key one-to-one puts a column in one table referencing the other, with a unique constraint enforcing the cardinality. Straightforward, but it needs an extra column and index, and the association can be null. A shared primary key one-to-one uses the same identifier value in both tables, with the dependent table's primary key also being a foreign key to the parent. @MapsId expresses this. The shared key approach is usually better for a true one-to-one. It saves a column, guarantees the cardinality structurally rather than by constraint, and makes the dependency explicit — the child cannot exist without the parent. The important performance detail concerns the optional side. A one-to-one from the parent to an optional child cannot be lazily loaded, because Hibernate must query to know whether the child exists in order to decide between a proxy and null. So it issues a query even when marked lazy, which surprises people who see an unexpected select. With @MapsId on the child side, the child's reference to the parent can be lazy, because the identifier is already known. So direction matters: put the association on the dependent side where possible.

29

When would you use @ElementCollection?

@ElementCollection maps a collection of basic types or embeddables owned entirely by the parent entity — stored in a separate table but with no identity of their own. A set of tags on a post, a list of phone numbers on a customer, a collection of embedded addresses. The distinction from @OneToMany is that the elements are not entities. They cannot be queried independently, have no identifiers, and their lifecycle is completely tied to the owner. The advantage is simplicity: you avoid creating an entity for something that is really just a value. The disadvantage is the update behaviour, which mirrors the unidirectional one-to-many problem. Hibernate typically deletes all rows and reinserts when the collection changes, which is expensive for large collections. Adding an @OrderColumn or using a Set of embeddables with proper equals and hashCode improves this, but it remains less efficient than an entity relationship. So the guidance is to use it for small collections of genuine values that change rarely and are always loaded with their parent. For anything large, frequently modified, or that you might want to query independently, model it as an entity instead.

30

How do you map an enum, and why is ORDINAL dangerous?

@Enumerated with either ORDINAL or STRING. ORDINAL stores the enum's position — 0, 1, 2. It is compact and it is the default, which is unfortunate because it is dangerous. The danger is that reordering the enum constants, or inserting a new one in the middle, silently changes the meaning of every stored row. A status of ACTIVE becomes SUSPENDED because someone added a constant alphabetically. There is no error, no migration, and the corruption is not visible until someone notices wrong data. STRING stores the constant name. It costs more space and requires care when renaming a constant, but a rename is at least a visible change that fails loudly if not migrated, rather than silently reinterpreting data. The recommendation is to always use STRING, and many teams enforce it with a lint rule since the default is the unsafe option. The third option for stability is a converter mapping each constant to an explicit code, decoupling the database representation from the Java name entirely. That is the most robust choice for enums that appear in long-lived data, because both renaming and reordering become safe.

31

What is an AttributeConverter and when is one useful?

An AttributeConverter defines a two-way mapping between an entity attribute type and a database column type, applied automatically or by annotation. It is useful whenever the Java type and the column type differ in a way JPA does not handle natively. Common cases: encrypting a column so the value is ciphertext at rest and plaintext in the model. Storing a value object such as Money or an EmailAddress as a single column while keeping the type in the domain. Mapping a legacy Y/N character column to a boolean. Converting a list to a delimited string or JSON for a column you do not want to normalise. It is also the clean way to stabilise enum storage with explicit codes. The practical benefits are that the conversion lives in one place rather than being repeated, and the domain model keeps meaningful types rather than raw strings. The caveats: converters do not apply to identifiers or version fields, and a converted column cannot be used in JPQL comparisons against the Java type in all cases, so queries may need the database representation. That surprises people and is worth checking before converting a column you query heavily.

32

How do you map a composite primary key?

Two mechanisms: @EmbeddedId with an @Embeddable key class, or @IdClass with the key fields duplicated on the entity. @EmbeddedId is generally preferred because the key is a proper type — you can pass it around, and finding by ID takes a single object rather than a constructed key class that must match field order. The key class must be serialisable, implement equals and hashCode correctly, and have a no-argument constructor. Getting equals and hashCode wrong here breaks the persistence context's identity map, producing duplicate entities and confusing behaviour. @IdClass keeps the fields directly on the entity, which some find more natural to query, but requires maintaining a parallel key class and is more error-prone. The broader advice worth giving is to avoid composite keys where you can. They complicate every foreign key referencing the entity, make joins verbose, and interact awkwardly with Spring Data derived queries. A surrogate single-column key with a unique constraint on the natural composite achieves the same integrity with much less friction. Composite keys are unavoidable for join tables with attributes and for some legacy schemas, which is when these mechanisms matter.

33

What is the difference between List, Set and Map for a collection mapping?

The choice affects both semantics and the SQL Hibernate generates, which is why it matters more than it appears. A Set has no duplicates and no order. Hibernate can issue targeted inserts and deletes when the collection changes, which makes it the most efficient choice for a many-to-many or a large collection. It requires correct equals and hashCode on the element. A List preserves order but, without an @OrderColumn, Hibernate treats it as a bag — it cannot identify individual rows positionally, so on modification it deletes all rows and reinserts. That is the well-known many-to-many performance trap. With @OrderColumn, order is persisted in an index column and updates are targeted, but reordering an element rewrites every subsequent index. @OrderBy is different and often what people actually want — it sorts on load using a SQL order by, without storing an order column. A Map keys elements by a property, which is convenient for lookup but less commonly used. The practical default is a Set with @OrderBy if you need sorted output, and a List with @OrderColumn only when the explicit ordering is genuinely part of the data.

34

How do you map a JSON column?

Modern Hibernate supports it directly with @JdbcTypeCode(SqlTypes.JSON) on a field whose type is a POJO, a Map or a String. Hibernate serialises to and from JSON automatically. Before that, the usual approach was an AttributeConverter serialising with Jackson, or the hibernate-types library which provided @Type(JsonType.class). The motivation is schema flexibility — storing attributes that vary per row, configuration, or a payload snapshot — without creating columns for every possible field or an entity-attribute-value table. The trade-offs deserve stating. Querying inside JSON requires database-specific operators, so it is not portable and JPQL cannot express it — you need native queries. Indexing is possible in PostgreSQL with GIN indexes but requires deliberate setup. And you lose the database's ability to enforce structure, so validation is entirely the application's responsibility. The dirty-checking caveat is worth knowing: Hibernate compares the serialised form, so mutating a nested object may or may not be detected depending on the type mapping. Replacing the whole value rather than mutating it in place avoids the ambiguity. Use it for genuinely unstructured data, not as a way to avoid schema design.

35

What is @MappedSuperclass and how does it differ from @Entity inheritance?

@MappedSuperclass defines mappings that subclasses inherit, but the superclass itself is not an entity — it has no table, cannot be queried, and cannot be the target of an association. It is shared field definitions rather than a type hierarchy. The typical use is a base class holding audit columns — id, createdAt, updatedAt, version — inherited by every entity. That avoids repeating the same five fields everywhere without implying any polymorphic relationship. The contrast with @Entity inheritance is that entity inheritance is a real type hierarchy: you can query for the parent type and receive instances of any subclass, and associations can point at the parent type. That requires one of the inheritance strategies and their attendant table structures. So the question to ask is whether you ever want to query across the subtypes together. If yes, you need entity inheritance. If you only want to avoid duplicating field declarations, @MappedSuperclass is simpler and cheaper. Using entity inheritance where a mapped superclass would do imposes a discriminator column or joins for no benefit, and it makes every polymorphic query scan more than it needs to.

36

How do you handle auditing fields like createdAt and updatedBy?

The simplest mechanism is JPA lifecycle callbacks — @PrePersist and @PreUpdate on a mapped superclass — setting timestamps before write. Spring Data JPA provides a more complete version: @CreatedDate, @LastModifiedDate, @CreatedBy and @LastModifiedBy, activated with @EnableJpaAuditing and an AuditorAware bean supplying the current user. That handles both timestamps and identity with no per-entity code. The alternative is database defaults and triggers, which have the advantage of applying to writes that bypass the application — direct SQL, migrations, other services. If correctness matters more than convenience, the database is the more reliable place. The trade is that database-set values are not reflected in the in-memory entity after write unless you refresh, which surprises people. For full change history rather than just last-modified stamps, Hibernate Envers records every revision to separate audit tables and lets you query state at any past point. It is powerful and costs a write per change plus significant storage, so it is worth it for regulated domains and overkill elsewhere. The detail commonly missed: use an instant in UTC, not a local date-time, or your audit trail is ambiguous.

37

What is the difference between @Column(nullable = false) and @NotNull?

They operate at different layers and both are usually wanted. @Column(nullable = false) is schema metadata. It affects DDL generation, adding a NOT NULL constraint if Hibernate creates the table. It does not validate anything at runtime — Hibernate will happily attempt the insert and let the database reject it. @NotNull is a Bean Validation constraint. Hibernate integrates with the validator and checks it before flushing, so you get a ConstraintViolationException in the application rather than a database error. The practical difference is where the failure surfaces and how usable it is. A validation exception names the field and can be mapped to a clean 400 response with field-level detail. A database constraint violation is a vendor-specific SQLException whose message you must parse to know which column failed. So use both: @NotNull for the runtime check and good error messages, @Column(nullable = false) so the schema enforces it regardless of who writes. The caveat is that in most production setups Hibernate does not generate DDL — migrations are managed by Flyway or Liquibase — so @Column(nullable = false) is documentation unless it matches the actual migration. Keeping them consistent matters.

38

Should you use ddl-auto in production?

No. hibernate.hbm2ddl.auto set to update or create in production is a well-known way to lose data or to end up with a schema nobody can reproduce. The problems: update only adds, it never removes or modifies, so the schema drifts and accumulates dead columns. It makes decisions Hibernate thinks are equivalent that may not be. It gives no review step, no rollback, and no record of what changed. And create drops everything, which needs no further explanation. It also means the schema is a side effect of the entity classes rather than a deliberate artefact, so two environments can diverge silently. The correct approach is versioned migrations with Flyway or Liquibase: each change is a reviewed, ordered, repeatable script checked into version control, applied identically everywhere, with a record of what has run. Set ddl-auto to validate in production, which checks that the schema matches the entity mappings and fails startup if not. That catches a missing migration immediately rather than at the first query. ddl-auto with create-drop is entirely reasonable for tests against an in-memory database, which is where it belongs.

39

What are the default fetch types and why do they matter?

@ManyToOne and @OneToOne default to EAGER. @OneToMany and @ManyToMany default to LAZY. Those defaults are widely considered wrong for the to-one cases, and knowing that is the point of the question. Eager to-one associations mean that loading an entity also loads everything it references, and transitively everything those reference. An Order with an eager Customer, whose Address is eager, pulls three tables on every load whether or not you need them. In a list query it multiplies. Worse, eager associations cannot be turned off per query — you are stuck with them everywhere, and a query that needs only the order pays for the whole graph. The practical recommendation is to mark every association LAZY explicitly, including the to-one ones, and then fetch what you need per use case with JOIN FETCH or an entity graph. That inverts the default: nothing is loaded unless asked for, and each query states its requirements. It costs a little more thought per query and it makes the cost visible, which is exactly what you want. The caveat is that lazy to-one requires bytecode enhancement to work optimally, otherwise Hibernate may still query.

40

What is JOIN FETCH and how does it differ from a plain join?

A plain join in JPQL constrains or filters the query but does not initialise the association — the results still have lazy proxies, and navigating them issues more queries. JOIN FETCH both joins and initialises, so the associated entities are loaded in the same query and are ready to use. That is the fix for N+1: one query with a fetch join replaces one query plus N. The limitations are important. You cannot paginate a query with a fetch join on a collection reliably. Because the join multiplies rows — one order with three items produces three rows — the database cannot apply a limit meaningfully. Hibernate detects this and applies pagination in memory, loading the entire result set and then slicing it, which is a serious and silent performance problem that logs a warning people miss. You also cannot fetch join more than one collection in a single query, because the Cartesian product of two collections multiplies rows unusably. Hibernate throws for this. The workarounds are to fetch one collection and use a batch size for the other, or to run separate queries and let the persistence context stitch them together.

41

What is an EntityGraph and when is it better than JOIN FETCH?

An entity graph declares which attributes to load, defined either with annotations on the entity or built programmatically, and applied as a query hint. The advantage over JOIN FETCH is reuse and separation. The same query method can be given different graphs for different callers, so you do not need a separate query string per fetch requirement. In Spring Data, @EntityGraph on a repository method achieves this declaratively. It also composes better: a named graph can be defined once and applied to find, to queries, and to repository methods. The two modes matter. A fetch graph treats attributes not in the graph as LAZY regardless of their mapping, giving you complete control. A load graph treats them according to their mapping, so eager associations still load. The limitations are similar to fetch joins. Multiple collection attributes in one graph produce the same Cartesian problem, and pagination with a collection has the same in-memory slicing issue. So the choice is stylistic for simple cases and favours entity graphs when the same query serves several use cases with different loading needs — which is common in a repository shared across services.

42

What is @BatchSize and how does it help with N+1?

@BatchSize tells Hibernate to initialise lazy associations in groups rather than one at a time. With a batch size of 25, accessing the association on the first of a hundred entities loads it for 25 of them in a single query using an IN clause. That turns N+1 into roughly N/batchSize + 1 queries. For a hundred entities with a batch size of 25, five queries instead of a hundred and one. The advantage over JOIN FETCH is that it does not multiply rows, so it works with pagination and with multiple collections — the two places fetch joins fail. It is also a global setting on the association rather than something each query must remember. It can be set per association with the annotation, or globally with hibernate.default_batch_fetch_size, which is a genuinely valuable default that many applications never set. The limitation is that it is still more than one query, so it is not as good as a single fetch join when a fetch join is possible. The pragmatic approach is to set a sensible global batch size as a safety net that bounds the worst case, then use fetch joins or graphs on the specific paths that matter.

43

What is a DTO projection and why is it often the best answer?

A DTO projection selects specific columns directly into a data transfer object rather than loading entities — either with a JPQL constructor expression, a Spring Data interface projection, or a Criteria construct. It is often the best answer for read paths because it sidesteps most ORM problems at once. No entities are loaded, so nothing enters the persistence context — no dirty checking, no snapshot memory, no flush cost. Only the requested columns are fetched, so no over-fetching. There are no proxies, so no lazy loading and no LazyInitializationException. And the result is already shaped for the API, so no separate mapping step. It also makes the query cost explicit: you can see exactly what is selected. The practical guidance is to use entities for write paths, where the lifecycle management and dirty checking earn their keep, and DTO projections for read paths, which are usually the majority of traffic. The cost is more classes and more explicit queries. Records make the DTO cheap, and the explicitness is arguably a benefit. A constructor expression cannot fetch collections, so a projection with nested lists needs either separate queries or a different approach.

44

Why does pagination with a fetch join produce a warning?

Because a fetch join on a collection multiplies rows, so the database's LIMIT no longer corresponds to entities. A query fetching ten orders with their line items produces one row per line item — perhaps forty rows for ten orders. Applying LIMIT 10 in SQL would return the first ten line items, which might belong to three orders. That is wrong. Hibernate handles it by removing the limit from the SQL, executing the query in full, and paginating in memory. It logs HHH000104 warning that firstResult and maxResults are being applied in memory. The consequence is severe and silent: a query intended to fetch ten rows loads the entire table. It works correctly in testing with small data and exhausts memory in production. The fixes: paginate over identifiers first with a query that has no fetch join, then fetch the full entities for that page of IDs in a second query with the join. Two queries, both correct and bounded. Or use @BatchSize instead of a fetch join, since batching does not multiply rows and pagination works normally. Or project into a DTO and assemble the collections separately.

45

What is the Cartesian product problem when fetching multiple collections?

Fetch joining two collections in one query multiplies their rows. An order with three line items and four status events produces twelve rows — the product, not the sum — with every combination repeated. With larger collections it explodes: two collections of a hundred elements each give ten thousand rows to transfer and deduplicate for a single entity. Hibernate detects this for bags and throws MultipleBagFetchException, which is confusing when first encountered but is protecting you. The workarounds. Change the collections to Set instead of List, which makes Hibernate accept the query — but this only silences the exception, the Cartesian product still occurs and the data transfer is still enormous. It is a trap that looks like a fix. The correct approaches: fetch one collection in the query and use @BatchSize for the others, which keeps row counts linear. Or run separate queries for each collection — the persistence context stitches them onto the same entity instances automatically, so two queries populate both collections correctly. Or use a DTO projection and assemble in application code. The general principle is that fetch joins are for one collection at a time.

46

What is bytecode enhancement and what does it enable?

Bytecode enhancement modifies entity classes at build time or load time to add capabilities Hibernate cannot achieve with proxies alone. Three main features. Lazy loading of basic attributes: without enhancement, all non-association columns load together, so a large blob or text column is fetched even when unused. With enhancement, individual attributes can be lazy. Proper lazy to-one associations: a proxy cannot represent a nullable one-to-one, because Hibernate must query to know whether to give you null. Enhancement lets the field be lazily initialised in place, so a lazy one-to-one actually is lazy. Dirty tracking: instead of comparing every entity against a snapshot at flush, enhanced entities record which fields changed as they change. That removes the snapshot memory and the comparison cost, which matters for large persistence contexts. The costs are build complexity — a Maven or Gradle plugin — and behaviour that differs subtly between enhanced and unenhanced runs, which can make debugging confusing. It is worth enabling for applications with large entities or heavy batch work. For a typical CRUD service the gains are modest and the build friction may not be worth it.

47

How do you decide between eager fetching everything and querying per use case?

Query per use case, essentially always. Eager fetching is a global decision made at mapping time that applies to every query, including ones written years later by someone who does not know it is there. It optimises for the case someone imagined and penalises every other. Per-use-case fetching means each query states what it needs, so a list view fetches shallowly and a detail view fetches deeply, and neither pays for the other. The cost is that you must think about fetching for each query, and that you get LazyInitializationException when you forget. That failure is loud and is arguably a feature — it tells you at development time that a query is under-specified, whereas eager fetching hides the cost. The practical arrangement: mark everything lazy in the mappings, set a global batch fetch size as a safety net so an overlooked case degrades to a few queries rather than hundreds, and use JOIN FETCH, entity graphs or DTO projections deliberately per query. Then assert query counts in tests for the important paths, so a regression is caught when someone adds a field access that reintroduces N+1.

48

What is the difference between fetch and load strategies in a query versus a mapping?

The mapping declares the default: whether an association is EAGER or LAZY when nothing else says otherwise. The query can override it upward — a JOIN FETCH or an entity graph can eagerly load something mapped lazy. The asymmetry is the point: you can make a lazy association eager for a specific query, but you generally cannot make an eager association lazy. Once mapped EAGER, every query pays, and there is no per-query escape in standard JPA. A fetch-type entity graph can approximate it, but support and behaviour vary. That asymmetry is the strongest argument for mapping everything lazy. Lazy is the flexible default because it can be overridden; eager is the rigid one. The practical consequence for design is that fetch decisions belong with the query, not with the entity. The entity describes structure; the query describes what this particular operation needs. Putting the fetch decision on the entity couples every use case to one imagined access pattern. The same reasoning applies to Spring Data: a repository method can carry an @EntityGraph, so different methods on the same repository can load different shapes of the same entity.

49

How do you fetch a paginated list with its associations efficiently?

The two-query approach, because a single query with a fetch join and pagination is broken. First query: select the identifiers for the page, with the filtering and ordering applied and a proper SQL limit. This has no join to collections, so rows correspond to entities and pagination works at the database. Second query: fetch the full entities with their associations for that specific list of identifiers, using a fetch join and an IN clause. No pagination is needed here since the set is already bounded. Hibernate places both results into the same persistence context, so the entities are fully initialised. The alternative is a single query without a fetch join, relying on a batch fetch size to load associations in groups. That is simpler, works with pagination naturally, and costs a few extra queries rather than one. For many cases it is good enough and requires no special code. Spring Data can express the two-query pattern with a countQuery and careful method design, though it often ends up as two explicit repository methods. What to avoid is the naive fetch join with a Pageable, which silently loads the whole table.

50

Why might a query return duplicate entities and how do you fix it?

A fetch join on a collection produces one row per collection element, so an order with three line items appears three times in the result list. Hibernate returns one entity instance per row, and since the persistence context guarantees one object per row identity, you get the same instance repeated. So the list has three references to the same order. The historical fix was SELECT DISTINCT, which Hibernate translated into a SQL DISTINCT — usually unnecessary since the duplicates are in the object list rather than in the database rows, and it forced the database to do extra sorting work. Hibernate 6 changed this: entity results are deduplicated in memory by default, so DISTINCT is no longer needed for this purpose and the query hint hibernate.query.passDistinctThrough is gone. On older versions, the recommended form was SELECT DISTINCT with the passDistinctThrough hint set to false, which deduplicated in memory without adding DISTINCT to the SQL. The deeper point is that this is a symptom of the row multiplication that also breaks pagination. If you are hitting it, check whether the query is also silently loading more than you think.

51

What is the read-only optimisation and why should you use it?

Marking a transaction or a query read-only tells Hibernate that no changes will be written, which lets it skip work. The main saving is dirty checking. Normally every loaded entity gets a snapshot, and at each flush Hibernate compares the entity against it. Read-only mode skips the snapshot entirely, halving the memory per entity and removing the comparison cost. It also sets the flush mode to MANUAL, so no automatic flush occurs before queries, removing that overhead. And it can propagate a read-only hint to the JDBC connection, which some databases and connection routing setups use to direct traffic to a replica — a significant architectural benefit. The practical impact is largest for queries returning many entities, where the snapshot memory dominates. It is enabled with @Transactional(readOnly = true) on service methods that only read, which is a large fraction of most applications and is very commonly omitted. The caveat is that it is a hint, not enforcement in all providers — Hibernate does honour it, but a change to a read-only entity is silently discarded rather than throwing, which can be confusing if applied to a method that does need to write.

52

How do you diagnose which queries Hibernate is actually issuing?

Turn on SQL logging, but do it properly. setting show-sql prints statements without parameters and without formatting, which is nearly useless. Better is the logger org.hibernate.SQL at DEBUG for the statements and org.hibernate.orm.jdbc.bind at TRACE for the bound parameters. Better still is a JDBC proxy — datasource-proxy or p6spy — which logs the complete statement with parameters inlined, plus execution time and a count per transaction. That count is the single most useful number, because N+1 shows up immediately as a query count proportional to result size. Hibernate can also report statistics: hibernate.generate_statistics gives query counts, cache hit ratios and entity load counts, which is good for spotting patterns across a whole run. For production, an APM tool with database instrumentation attributes queries to endpoints, which is how you find the slow path. The practice worth adopting is asserting query counts in integration tests for key endpoints. A test that fails when an operation goes from three queries to fifty catches the regression at the commit that caused it, rather than in production three months later.

53

What is JPQL and how does it differ from SQL?

JPQL queries the entity model rather than the database schema. You name entities and their fields, not tables and columns, and the provider translates to SQL. The practical differences: you traverse associations with dot notation rather than writing joins — order.customer.name generates the join for you. Polymorphic queries work, so selecting a parent entity type returns subclass instances. And the query is portable across databases. The limitations matter. JPQL cannot express many SQL features: window functions, CTEs, database-specific functions, and complex set operations are either impossible or awkward. It has no support for DML beyond simple bulk update and delete. It is also string-based, so errors surface at runtime rather than compile time — though named queries are validated at startup, which is a good reason to use them. The practical guidance is to use JPQL for straightforward entity queries where its association traversal earns its keep, and to drop to native SQL for reporting, aggregation and anything using database-specific capability. Using native SQL is not a failure of the ORM; insisting on JPQL for a complex analytical query is.

54

When would you use the Criteria API?

For queries built dynamically, where the structure depends on runtime input. The classic case is a search with optional filters. With JPQL you end up concatenating strings conditionally, which is error-prone and vulnerable if any user input reaches the string. With Criteria you build a list of predicates and combine them, so the query is assembled programmatically and parameters are always bound. The secondary benefit is type safety with the metamodel — generated classes let you reference fields as Order_.status rather than the string "status", so a field rename becomes a compile error rather than a runtime one. The cost is verbosity. Criteria code is substantially longer and harder to read than the equivalent JPQL, and a moderately complex query becomes genuinely difficult to follow. Many teams find it a poor trade for anything static. Spring Data Specifications wrap Criteria in a more composable form, which improves the ergonomics considerably and is the usual way people actually use it. For genuinely complex dynamic queries, jOOQ or QueryDSL offer type safety with far better readability, and are worth considering over Criteria if you have the freedom to add a dependency.

55

How do you prevent SQL injection with JPA?

Use bound parameters, never string concatenation. setParameter with a named or positional parameter passes the value separately from the query, so it is never parsed as SQL. That applies to JPQL, native queries, and Criteria. The vulnerability appears when someone builds a query by concatenating user input, which happens most often with dynamic filters or a sortable column name. A search endpoint that appends a WHERE clause from a request parameter is exploitable. The part people miss is that parameters cannot be used everywhere. You can bind a value but not an identifier — a column name in an ORDER BY, or a table name, cannot be a bound parameter. So dynamic sorting is exactly the case where concatenation is tempting and dangerous. The fix there is an allowlist: map the client-supplied sort key to a known column name, and reject anything not on the list. Never pass it through. Spring Data derived queries and Specifications are safe by construction, which is another argument for them. And native queries deserve the same discipline as JDBC — the ORM does not protect you if you hand it a concatenated string.

56

What is a native query and what do you lose by using one?

A native query is raw SQL executed through the EntityManager, optionally mapped back to entities or to a result set mapping. You use it when JPQL cannot express what you need: window functions, CTEs, database-specific functions, hints, or complex analytical queries. What you lose: portability, since the SQL is vendor-specific. Compile-time and startup validation, since Hibernate does not parse it. And, importantly, some persistence context integration — Hibernate cannot tell which tables a native query touches as reliably, so it may flush more than necessary before executing, and it cannot automatically invalidate second-level cache regions unless you tell it which entities are affected. That last point is a real correctness issue: a native update that modifies rows behind Hibernate's back leaves the persistence context and the second-level cache stale. Entities already loaded keep their old values. The mitigations are to declare the affected entity classes on the query so Hibernate can synchronise, and to clear the persistence context after bulk native modifications. Used deliberately for reads, native queries are entirely reasonable. Used for writes, they need care about what the ORM now believes.

57

How do bulk update and delete queries interact with the persistence context?

They bypass it entirely, and that is both the point and the hazard. A JPQL bulk update executes a single SQL statement against the database. It does not load entities, does not fire lifecycle callbacks, does not cascade, and does not update the version column unless you write it explicitly. The performance benefit is enormous — updating a million rows with one statement rather than loading a million entities. The hazard is staleness. Entities already in the persistence context are not updated, so code that loaded an entity, then ran a bulk update affecting it, then read the field, sees the old value. The context believes its copy is current. The standard remedy is to run bulk operations first in a transaction, or to clear the persistence context afterwards so subsequent reads go to the database. The other omissions matter: no cascade means child rows are not deleted, so a bulk delete can violate foreign keys or orphan data. No version increment means optimistic locking is silently defeated for those rows. So bulk operations are the right tool for large volumes, applied deliberately with those consequences in mind.

58

What is the difference between getSingleResult, getResultList and Spring Data return types?

getSingleResult returns exactly one result and throws otherwise — NoResultException if there are none, NonUniqueResultException if there are several. Using it for a lookup that may legitimately find nothing means exception-driven control flow, which is both slow and clumsy. getResultList returns a list, empty if nothing matched, and never throws for cardinality. Safer, but you handle the size yourself. JPA added getResultStream, which is useful for large results if the provider streams rather than materialising, though behaviour varies. Spring Data is better here: a repository method can return Optional for zero-or-one, which expresses absence in the type rather than by exception, or a List, or a Page for paginated access, or a Stream. The practical guidance is to return Optional for lookups that may find nothing, and reserve exceptions for genuinely exceptional cases. One subtlety worth knowing: Spring Data's Optional-returning method still throws IncorrectResultSizeDataAccessException if the query matches more than one row, so Optional does not protect against a missing unique constraint. That surprises people who assume it silently takes the first.

59

How does pagination work in Spring Data and what is the count query cost?

A repository method taking a Pageable returns a Page, and Spring Data executes two queries: one for the page of data with a limit and offset, and one COUNT to compute the total number of elements and pages. That count query is the cost people overlook. On a large table with a complex filter it can be far more expensive than fetching the page, because it must evaluate the whole filtered set and cannot use the limit. The alternative is to return Slice instead of Page. A Slice fetches one extra row to determine whether a next page exists, and skips the count entirely. If your UI only needs a next button rather than a page count, that is a substantial saving. For very large offsets there is a second problem: OFFSET makes the database scan and discard all preceding rows, so deep pages get progressively slower. Keyset pagination — filtering on the last seen sort value rather than using an offset — avoids that entirely and is the right approach for large datasets. And the familiar warning applies: pagination combined with a fetch join on a collection paginates in memory, which is silently catastrophic.

60

What are named queries and why use them?

A named query is defined once with @NamedQuery on an entity, or in XML, and referenced by name rather than written inline. The main benefit is startup validation. Hibernate parses named queries when the persistence unit initialises, so a syntax error or a reference to a non-existent field fails fast at boot rather than at runtime when someone finally hits that code path. That is a genuine safety improvement over inline JPQL strings. Secondary benefits: the query is defined in one place so it can be reused, and it can be overridden per deployment via XML without recompiling. The downsides are that the query lives away from the code using it, which hurts readability, and that annotation-based named queries clutter the entity with concerns that are not really about the entity. In Spring Data, @Query on a repository method gives most of the benefit — Spring validates these at startup too — while keeping the query next to the method that uses it. That is usually the better arrangement in practice. The key thing either way is that a query validated at startup beats one discovered broken in production.

61

How do you query across a polymorphic entity hierarchy?

Selecting the parent entity type returns instances of every subclass, which is one of the genuine benefits of entity inheritance over a mapped superclass. The SQL generated depends on the strategy. SINGLE_TABLE gives one query with a discriminator predicate — efficient. JOINED gives a query with left joins to every subclass table, so the cost grows with the number of subclasses. TABLE_PER_CLASS gives a UNION across all tables, which is the slowest and prevents some optimisations. You can restrict to a subtype with TYPE in the where clause, or by selecting the subclass directly. The practical caution is that polymorphic queries on a wide hierarchy can be expensive in ways that are not obvious from the JPQL — a simple-looking select against the parent may generate a query joining eight tables. The related trap is polymorphic associations: a @ManyToOne to a parent type with JOINED inheritance means every load of the owning entity joins the whole hierarchy to determine the concrete type. If you rarely query polymorphically, a mapped superclass with separate entities avoids all of this and is often the better model.

62

What is the query cache and when is it worth enabling?

The query cache stores the identifiers returned by a query, keyed by the query string and its parameters. On a hit, Hibernate has the ID list and loads the entities — from the second-level cache if they are there. That dependency is the crucial point: the query cache only stores IDs, not entities. Without the second-level cache enabled for those entities, a query cache hit still issues a select per identifier, which can be worse than the original query. So the two must be enabled together. Invalidation is aggressive. Any modification to a table invalidates every cached query touching that table, because Hibernate cannot know which results are affected. For a frequently-written table the cache is invalidated constantly and provides no benefit while adding overhead. So it is worth enabling only for queries over data that is read constantly and written rarely — reference data, configuration, catalogue lookups — and it must be enabled per query with a hint rather than globally. The honest assessment is that it is often not worth it. An application-level cache with a clear invalidation policy usually gives more benefit with less surprising behaviour.

63

How do you handle a query that needs database-specific features?

Use a native query, and isolate it. Window functions, CTEs, full-text search, JSON operators, array types and vendor hints are all things JPQL cannot express. Attempting to work around that in application code — fetching more rows and filtering in Java — is usually far worse than accepting a native query. The isolation matters. Keep native SQL in the repository layer behind a method with a domain-meaningful name, so the rest of the application depends on the operation rather than on the SQL. If the database changes, one file changes. Hibernate also lets you register custom functions with the dialect, so a database function can be called from JPQL. That preserves more of the JPQL structure and is worth knowing for cases where only one function is the obstacle. For result mapping, @SqlResultSetMapping maps native results to entities or to constructor projections, which avoids manual row handling. The judgement point: the goal of portability is usually theoretical, since almost nobody changes database vendor. Trading real performance and clarity for hypothetical portability is a bad deal, and saying so plainly is a reasonable interview answer.

64

What is the difference between a Spring Data derived query and @Query?

A derived query is generated from the method name — findByStatusAndCreatedAtAfter parses into a query with those predicates. No query is written at all. @Query supplies explicit JPQL or native SQL on the method. Derived queries are excellent for simple cases: they are concise, self-documenting, and validated at startup. Their limit is readability. A method name encoding four conditions, an ordering and a limit becomes unreadable and unmaintainable — findByStatusAndCustomerCountryAndCreatedAtBetweenOrderByTotalDesc is a real thing people write and nobody enjoys. The rough rule is that beyond two or three conditions, an explicit @Query is clearer. @Query also gives access to things derived queries cannot express: joins with fetch, projections into DTOs, subqueries, and native SQL. Both are validated at application startup, so both fail fast on error. For genuinely dynamic queries where the conditions vary at runtime, neither works and you need Specifications or Criteria. The practical arrangement is derived methods for simple lookups, @Query for anything with joins or projections, and Specifications for dynamic filtering — using each where it is strongest rather than forcing one everywhere.

65

What are the transaction isolation levels and what problems does each prevent?

READ UNCOMMITTED allows dirty reads — seeing another transaction's uncommitted changes, which may be rolled back. Almost never appropriate. READ COMMITTED prevents dirty reads by only showing committed data. It still allows non-repeatable reads: reading the same row twice in one transaction can give different values if another transaction committed in between. This is the default in PostgreSQL, Oracle and SQL Server. REPEATABLE READ additionally guarantees that re-reading a row gives the same value. It may still allow phantom reads, where a range query returns different rows because another transaction inserted matching ones. This is MySQL InnoDB's default, and InnoDB actually prevents phantoms too via next-key locking. SERIALIZABLE prevents all of these by making transactions behave as if executed one at a time. Correct but expensive, with more locking or more serialisation failures depending on the implementation. The practical guidance: READ COMMITTED is the right default for most applications, and where you need stronger guarantees for a specific operation, explicit locking or optimistic version checks are usually cheaper than raising the isolation level globally.

66

What is optimistic locking and how does @Version work?

Optimistic locking assumes conflicts are rare. Rather than locking rows, it detects at write time whether anyone else modified them. @Version marks a field — an integer or a timestamp — that Hibernate manages. On every update it includes the current version in the WHERE clause and increments it. If the update affects zero rows, someone else changed the row since it was read, and Hibernate throws OptimisticLockException. That prevents the lost update problem: two users read the same record, both modify it, and without a version the second silently overwrites the first with no error and no trace. The advantages are no locks, no blocking, and no deadlocks — which makes it the right default for typical web applications where the same row is rarely contended. The cost is that the losing transaction must be retried or reported, and the user may have to redo work. So the exception must be handled deliberately rather than surfacing as a 500. The caveat worth naming: bulk update queries do not increment the version unless you write it explicitly, which silently defeats the mechanism for those rows.

67

When would you use pessimistic locking instead?

When conflicts are likely enough that optimistic retry is wasteful, or when the work between read and write is expensive and you do not want to discard it. Pessimistic locking acquires a database lock when reading — PESSIMISTIC_READ for a shared lock, PESSIMISTIC_WRITE for an exclusive one, which translates to SELECT FOR UPDATE. Other transactions block until you commit. The classic cases are inventory decrements where many users compete for the same row, seat or ticket allocation, and any counter under contention. There, optimistic locking would produce a storm of failed transactions and retries. The costs are real: blocking reduces throughput, held locks can cause deadlocks if acquired in inconsistent order, and a long transaction holding a lock stalls everyone behind it. So the rules are to hold locks briefly, acquire them in a consistent order, and always set a lock timeout so a stuck transaction fails rather than blocking indefinitely. JPA supports a timeout hint for this. A useful middle path for counters is a single atomic SQL update rather than read-modify-write, which needs no lock at all because the database serialises it.

68

What are the transaction propagation types and when do they matter?

REQUIRED is the default: join the existing transaction, or start one if none exists. Correct for almost everything. REQUIRES_NEW suspends any existing transaction and starts an independent one. It commits or rolls back separately, so it is right for work that must persist regardless of the outer transaction's outcome — writing an audit record for a failed operation, for instance. The cost is a second database connection held simultaneously, which can exhaust the pool if used carelessly. SUPPORTS joins a transaction if present but runs without one otherwise. MANDATORY requires an existing transaction and throws if absent, which is a useful guard on methods that must never run standalone. NEVER and NOT_SUPPORTED are the inverses. NESTED uses savepoints so the inner work can roll back without discarding the outer transaction, but support is limited and it is rarely used. The practical point is that REQUIRES_NEW is the one that causes trouble. People reach for it to isolate a failure and inadvertently create a deadlock — the outer transaction holds a lock the inner one needs, and neither can proceed.

69

What happens if an exception is thrown inside a transaction?

By default Spring rolls back on RuntimeException and Error, and commits on checked exceptions. That default surprises people and causes real bugs: a method that throws a checked exception to signal failure gets its changes committed. Use rollbackFor to change it, or throw unchecked exceptions for failures that should roll back. The second surprise is that catching the exception inside the transactional method prevents rollback, because the proxy never sees it. Code that catches, logs and continues has committed whatever was written before the failure. The third is transaction-scoped state after a rollback. Once a transaction is marked rollback-only, any further work in it fails with UnexpectedRollbackException at commit — which is confusing because the original cause is elsewhere. This happens when an inner REQUIRED transactional method throws, is caught by the caller, and the caller tries to continue: the inner method already marked the shared transaction rollback-only. And after any exception, the persistence context is in an undefined state and should not be reused — Hibernate documentation is explicit that the session must be discarded after an exception.

70

How long should a transaction be, and what are the costs of a long one?

As short as correctness allows. A transaction holds a database connection for its entire duration, so long transactions exhaust the connection pool under load — often the first symptom, appearing as timeouts acquiring connections rather than as anything obviously transactional. They hold locks longer, increasing contention and the chance of deadlock. Under MVCC they force the database to retain older row versions, growing the undo log or bloating tables, which is a real operational problem in PostgreSQL with long-running transactions. They also grow the persistence context, since every entity touched stays managed with a snapshot, which is a memory cost and a dirty-checking cost at every flush. The practical rules: never perform network calls to external services inside a transaction — an unresponsive third party then holds your database connection. Never wait for user input inside one. Do computation before opening it where possible. For long workflows, break them into several short transactions and handle partial completion explicitly with a saga or state machine, rather than one transaction spanning the whole thing.

71

What is the lost update problem and how do you prevent it?

Two transactions read the same row, both modify it, and the second write overwrites the first. The first user's change disappears with no error — which is what makes it dangerous. It is not prevented by READ COMMITTED, and it is not usually prevented by REPEATABLE READ either in the read-modify-write pattern that applications use. The standard prevention is optimistic locking with @Version. The second write includes the version it read in the WHERE clause, matches zero rows, and fails loudly instead of silently overwriting. The alternative is pessimistic locking — SELECT FOR UPDATE at read time — so the second transaction blocks until the first commits, then reads the updated value. A third option, for simple cases, is to avoid read-modify-write entirely: an atomic UPDATE that computes the new value in SQL, such as setting balance = balance - 100, is serialised by the database and cannot lose an update. The design point worth making is that this is not an exotic edge case. Any application where two users can edit the same record has it, and most applications with no version column have it silently.

72

What is the difference between OPTIMISTIC and OPTIMISTIC_FORCE_INCREMENT?

OPTIMISTIC checks at commit that the entity's version has not changed since it was read, even if you did not modify it. That protects a read you depended on — you read a product's price, made a decision, and want to be sure nobody changed it meanwhile. OPTIMISTIC_FORCE_INCREMENT does the same check and additionally increments the version even though the entity was not modified. The reason to force an increment is aggregate-level consistency. If you modify a child entity, the parent's version does not change, so another transaction reading the parent sees no conflict — yet the aggregate as a whole has changed. Forcing an increment on the parent makes the modification visible to anyone version-checking the parent. That is how you extend optimistic locking from a single row to an aggregate boundary, which matters when invariants span the parent and its children. The pessimistic equivalents follow the same pattern: PESSIMISTIC_FORCE_INCREMENT takes a lock and bumps the version. This is a genuinely advanced area and knowing it exists is usually enough — the practical takeaway is that a plain @Version protects one row, not an aggregate.

73

How do you handle transactions across multiple services or databases?

You generally cannot, and the answer is to stop trying. Two-phase commit exists and JTA implements it, but it has serious costs: it holds locks across the whole protocol, blocks if the coordinator fails at the wrong moment, and is poorly supported by modern systems — most message brokers and cloud databases do not participate. The practical alternative is the saga pattern: break the operation into local transactions, each of which commits independently, with compensating actions to undo earlier steps if a later one fails. The system is eventually consistent, and you must design the compensations deliberately — refunding a payment rather than rolling it back. The pattern that solves the most common case is the transactional outbox. When you need to update the database and publish an event, write the event to an outbox table in the same local transaction, and have a separate process read the outbox and publish. That gives atomicity between the state change and the intent to publish, without a distributed transaction. Without it, you either publish and then fail to commit, or commit and then fail to publish — the dual-write problem.

74

What is a connection pool and how do you size one?

A pool keeps database connections open and hands them out, avoiding the cost of establishing a connection — a TCP handshake plus authentication — per query. Sizing is counterintuitive: the right pool is usually much smaller than people expect. HikariCP's guidance, derived from PostgreSQL benchmarks, is roughly cores times two plus effective spindle count — often ten to twenty, not hundreds. The reason is that a database cannot execute more concurrent queries than it has resources for. Beyond that point, more connections mean more context switching and lock contention, and throughput falls while latency rises. A large pool converts a queue you can measure into contention you cannot. The practical failure to watch for is pool exhaustion: all connections held, new requests timing out. The cause is almost never the pool being too small — it is transactions being held too long, usually because of an external call inside a transaction or Open Session In View holding a connection for the whole request. So the diagnostic order is to look at transaction duration first and pool size second. Also set a connection timeout, or exhaustion becomes an indefinite hang rather than a fast failure.

75

What is the difference between flush and commit?

Flush writes pending changes from the persistence context to the database as SQL statements. Commit ends the transaction and makes those changes permanent and visible to others. A flush without a commit means the statements have executed but the transaction is still open — the changes are visible within this transaction, hold locks, and will be discarded if you roll back. So flushing does not save anything durably. Code that calls flush expecting the data to be safe is mistaken; a subsequent rollback undoes it. Commit always flushes first, so an explicit flush before commit is redundant. The reasons to flush explicitly are to force generated identifiers to be assigned, to make changes visible to a native query that Hibernate cannot associate with the entities, or to control statement ordering — the classic case being a delete followed by an insert with the same unique key, which fails because Hibernate reorders inserts before deletes. The other reason is batch processing, where you flush and clear periodically to bound memory. The common misconception worth correcting is that flush is a lightweight save. It is a synchronisation point, not a durability boundary.

76

How do you test transactional code?

Against a real database, using the same engine as production. An in-memory database such as H2 is fast but behaves differently — different SQL dialect, different constraint handling, different locking semantics — so tests pass while production fails. Testcontainers starts a real PostgreSQL or MySQL in Docker per test run, which removes that whole class of false confidence and is now the standard approach. For isolation between tests, the usual approach is @Transactional on the test with automatic rollback, which is fast and leaves no state behind. The caveat is that it changes behaviour: the test runs inside a transaction, so code under test that expects its own transaction boundary — or that relies on commit-time behaviour — is not exercised faithfully. Testing anything involving REQUIRES_NEW or commit hooks needs a different approach, usually explicit cleanup instead of rollback. The rollback approach also hides constraint violations that only fire at commit. For optimistic locking, a test needs two persistence contexts to simulate concurrent modification — loading the same entity twice, modifying both, and asserting the second write fails. And assert query counts, which is where the ORM regressions actually live.

77

When should you enable the second-level cache?

For entities that are read far more often than written, and where staleness for a short window is acceptable. Good candidates: reference data such as countries, currencies and categories; configuration; product catalogues; anything loaded on nearly every request and changed weekly. Bad candidates: entities modified frequently, where invalidation churn outweighs the benefit; entities with huge instance counts that would not fit; and anything where stale data has real consequences. The operational complications matter more than the configuration. In a clustered deployment the cache must be distributed or invalidated across nodes, or two instances serve different data — which produces bugs that only appear behind a load balancer. Any write that bypasses Hibernate — a native query, a migration, another service, a DBA — leaves the cache stale with no mechanism to detect it. And the concurrency strategy must match the data: READ_ONLY for data that never changes, NONSTRICT_READ_WRITE where brief staleness is fine, READ_WRITE for correctness with soft locks, TRANSACTIONAL for full isolation with a JTA provider. The honest position is that an explicit application cache with a clear invalidation policy is often simpler to reason about.

78

What are the second-level cache concurrency strategies?

READ_ONLY is for data that never changes after insert. No locking overhead, and attempting an update throws. The fastest and safest where it applies — reference data is the typical case. NONSTRICT_READ_WRITE invalidates the cache entry after a transaction commits, without locking. There is a small window where a stale value can be read, so it is suitable when occasional staleness is harmless. Cheap. READ_WRITE uses soft locks: the entry is locked during the write and released after commit, so readers do not see intermediate state. It provides read-committed semantics and is the usual choice for mutable data. More overhead, and it requires the cache provider to support it. TRANSACTIONAL gives full transactional isolation with the cache participating in a JTA transaction. Correct but requires a JTA provider and a cache that supports it, and it is rarely used. The practical guidance is READ_ONLY wherever the data genuinely is, READ_WRITE for mutable entities, and to think carefully before NONSTRICT_READ_WRITE because the staleness window is real. Choosing wrongly here produces intermittent wrong data rather than an error, which is the worst kind of bug.

79

How do collections interact with the second-level cache?

Collections are cached separately from the entities they contain, and only the identifiers are stored — not the entity state. So caching a collection stores the list of child IDs. Reading it from cache gives you those IDs, and Hibernate then loads each entity — from the entity cache if it is enabled for that type, or from the database if not. That is the crucial dependency: caching a collection without also caching the entity type it holds converts one query into N queries. It makes things worse, not better. Collection caching must be enabled explicitly with @Cache on the association, in addition to caching on both entity types. Invalidation is also coarse: modifying the collection invalidates the whole cached list, so a frequently-changing collection gains nothing. The practical guidance is to cache collections only when the collection membership is stable and the contained entities are also cached — a product's category list, not an order's line items. And verify with statistics rather than assuming. Hibernate's cache hit and miss counters will show immediately whether a cached collection is actually being served from cache or thrashing.

80

What cache invalidation problems does Hibernate not solve for you?

Anything that changes the database without going through Hibernate. A native query issued through the EntityManager can be told which entity classes it affects, and Hibernate will invalidate those regions — but only if you tell it. Omit that and the cache silently holds stale data. A database migration, a manual fix by a DBA, a batch job written in another language, or a second application writing to the same tables are all invisible to Hibernate. The cache keeps serving old values indefinitely, and there is no mechanism to detect it. In a cluster, an update on one node invalidates that node's cache. Without a distributed cache or an invalidation channel, other nodes keep their stale copies — so behaviour depends on which instance served the request, which is exactly the kind of bug that is impossible to reproduce. The mitigations: use a distributed cache with proper invalidation for clustered deployments; declare affected entities on native queries; and accept a bounded TTL so stale data eventually expires even if invalidation is missed. The broader lesson is that caching adds a consistency problem, and Hibernate only manages the part it can see.

81

What is the difference between caching in Hibernate and caching in the application?

Hibernate's second-level cache stores entity state by identifier, transparently. You enable it and reads are served from memory without changing the code. An application cache — Redis, Caffeine, a Spring @Cacheable method — stores whatever you choose at whatever granularity you choose: a DTO, a computed result, an entire rendered response. The advantages of the application cache are control and granularity. You cache the thing the caller actually needs rather than the entity, so you skip the mapping and the object construction too. Invalidation is explicit, so you know exactly what happens on a write. And it works across services, not just within one Hibernate deployment. The advantage of the second-level cache is that it requires no code changes and it integrates with the persistence context, so entities remain managed. The practical pattern in most systems is to cache at the application layer for expensive read paths, caching DTOs rather than entities, and to use the second-level cache narrowly for genuinely static reference data. Caching entities in Redis directly is usually a mistake, because a deserialised entity is detached and its proxies are broken.

82

How do you verify that caching is actually helping?

Measure, because caching frequently makes things worse in ways that are invisible without instrumentation. Enable hibernate.generate_statistics and read the cache hit, miss and put counts per region. A region with many puts and few hits is pure overhead — you are paying to populate a cache nobody reads. A high miss ratio means the working set exceeds the cache size, and you are paying for eviction churn. Compare query counts before and after with a JDBC proxy, which shows whether the cache is actually removing database round trips. Measure latency at the percentile you care about rather than the mean, since caching often improves the median while leaving the tail unchanged — and the tail is what users notice. Watch memory. A cache that is helping latency and pushing you toward garbage collection pressure or an OOM is not a net win. And test with production-like data volumes. A cache that holds the entire dataset in a test environment and a fraction of it in production behaves completely differently. The general discipline is that caching is an optimisation, and adding one without a before-and-after measurement is guessing.

83

What is a cache region and why does it matter?

A region is a named partition of the second-level cache, typically one per entity type or collection, configured independently. It matters because different data has different characteristics. A country list is tiny, never changes and should never expire. A product catalogue is large, changes daily and needs a size limit. Giving them the same eviction policy and size means one starves the other. Per-region configuration lets you set the maximum entry count, the time to live and the time to idle appropriately for each. Without regions — or with everything in a default region — a burst of activity on one entity type evicts everything else, and the cache you actually wanted stops working. That is a real and hard-to-diagnose failure: your reference data cache stops hitting because a batch job loaded a million transactions through the same region. Regions are also the unit of invalidation and of statistics, so they are what you monitor. The practical advice is to name regions explicitly rather than relying on defaults, size them from actual cardinality rather than guessing, and set a time to live even on data you believe never changes — as insurance against the invalidation you missed.

84

Should you cache at the entity level or the query level?

Entity level, generally, because it is simpler and invalidates more precisely. Entity caching keys on the identifier, so a change to one row invalidates exactly one entry. Any code path loading that entity by ID benefits, regardless of how it got the ID. Query caching keys on the query string plus parameters, and stores only identifiers. It depends on the entity cache to be useful at all, and its invalidation is coarse: any write to a table invalidates every cached query touching that table, because Hibernate cannot determine which results changed. That coarseness means query caching only works for tables that are almost never written. On a table with regular writes, cached queries are invalidated constantly and you pay the overhead for nothing. So the practical order is: cache entities for reference data, and add query caching only for specific expensive queries over genuinely static tables, enabled per query rather than globally. And for the common case of an expensive read that returns a projection rather than entities, neither mechanism helps much — an application-level cache holding the DTO is the better answer, because it caches the finished result rather than the ingredients.

85

What are the most common Hibernate performance problems in production?

N+1 queries, by a wide margin. Lazy associations navigated in a loop, usually invisible in the code because it looks like ordinary object access. Over-fetching: eager associations pulling an object graph nobody needed, or selecting entities when a projection of three columns would do. Pagination with a fetch join, which silently loads the entire result set into memory and paginates there. Unbounded persistence contexts in batch jobs — every entity managed with a snapshot until the transaction ends, producing OutOfMemoryError. Missing read-only on read transactions, so every query pays for snapshots and dirty checking it will never use. IDENTITY generation preventing insert batching, which makes bulk writes an order of magnitude slower than they need to be. Cascade REMOVE on large collections, loading every child to delete them one at a time. And the unidirectional one-to-many delete-and-reinsert behaviour, which turns adding one child into hundreds of statements. The common thread is that all of these are invisible in the Java code and obvious in the SQL log — which is why counting queries is the single most valuable diagnostic habit.

86

How do you enable JDBC batching and why is it often not working?

Set hibernate.jdbc.batch_size to a value such as 30, and set order_inserts and order_updates to true so statements are grouped by table — batching only works on consecutive statements against the same table, so without ordering the batches break constantly. The reason it is often silently disabled is the identifier strategy. With GenerationType.IDENTITY, Hibernate must execute each insert immediately to obtain the generated key, so it cannot queue them. Batching is disabled entirely for inserts, with no warning. The fix is SEQUENCE generation with a pooled optimiser and an allocation size matching the batch size, so identifiers are obtained in blocks and inserts can be queued. Other reasons batching fails: a flush between operations breaks the batch; mixing entity types without ordering breaks it; and some JDBC drivers need a connection property to actually batch — MySQL requires rewriteBatchedStatements=true, without which the driver sends statements individually despite Hibernate batching them. That MySQL flag is a common and dramatic finding, often worth an order of magnitude. Verify with statistics or a JDBC proxy rather than assuming the configuration took effect.

87

Why might Hibernate issue an update you did not ask for?

Because dirty checking persists any change to a managed entity, whether or not you called save. The usual causes. Code that loads an entity, modifies a field for a calculation or normalisation, and does not intend to store it — the change is written at commit anyway. A getter with a side effect, or a lazy initialisation that mutates a field. An entity with a mutable field type where Hibernate's comparison sees a difference that is not real — a Date, a collection, or a JSON-mapped object where serialisation is not stable. That produces an update on every flush even with no logical change. A @UpdateTimestamp or auditing listener firing on any flush. An embeddable or collection whose equals and hashCode are wrong, so Hibernate cannot tell it is unchanged. The diagnostic is to log the SQL and see which columns are being set. The fixes are to mark read-only transactions as such, to avoid mutating entities you do not intend to persist, and to check equality implementations on custom types. @DynamicUpdate limits the update to changed columns, which reduces the damage but does not stop the update firing.

88

What is @DynamicUpdate and when is it worth using?

By default Hibernate generates one UPDATE statement per entity type, setting every column, and caches that statement. @DynamicUpdate makes it generate a statement containing only the columns that actually changed. The benefits: less data sent to the database; fewer index updates, since unchanged indexed columns are not written; and reduced lock contention in some databases, since narrower updates conflict less. It also matters when the table has large columns — updating a row without rewriting a large text or blob column is a real saving. And it avoids overwriting a column that another transaction changed, in the specific case where you did not modify it. The cost is that the SQL cannot be cached, since it varies per update, so Hibernate builds the statement each time and the database re-parses it. For a table with few columns and frequent updates that overhead can exceed the benefit. So the guidance is to use it selectively on wide tables or tables with large columns, and to measure rather than applying it everywhere. @DynamicInsert is the equivalent for inserts, omitting columns that are null so database defaults apply.

89

How do you handle a very large result set without exhausting memory?

Do not load it as entities. The options in order of preference. Use pagination with keyset rather than offset, processing a bounded page at a time and clearing the persistence context between pages. Use a projection into a DTO, which avoids the persistence context and the snapshot entirely — the results are not managed, so they are eligible for collection as you go. Use a scrollable result set or a Stream, which fetches rows incrementally rather than materialising the whole list. This requires the right fetch size on the JDBC driver and a transaction that stays open, and you must close the stream. Note that PostgreSQL's driver only streams when autocommit is off and a fetch size is set, which catches people out. Use StatelessSession, which has no persistence context, no dirty checking, no cascade and no cache — designed exactly for this. Or do the work in the database with a single SQL statement, which is usually the fastest answer by a wide margin. The general principle is that pulling a million rows into application memory to process them one at a time is the wrong shape, whatever the mechanism.

90

What is the impact of Open Session In View on the connection pool?

It holds a database connection for the entire HTTP request rather than for the duration of the transaction. So a request that spends 20 ms querying and 200 ms rendering, serialising and writing to a slow client holds a connection for the full 220 ms. The connection is idle for most of that time but unavailable to anyone else. The consequence is that pool capacity is consumed by request duration rather than by database work. A pool of twenty connections that could support hundreds of requests per second now supports far fewer, and the limit is reached long before the database is under any pressure. The symptom is timeouts acquiring connections while the database shows low utilisation — which sends people to increase the pool size, making contention at the database worse without fixing the cause. It is worse with slow clients, since the connection is held while the response is written over the network. Disabling OSIV means connections are returned when the transaction ends, and the pool serves many more concurrent requests. The cost is that you must fetch deliberately, which is the discipline you wanted anyway. Spring Boot enables it by default, so this is worth checking on any inherited application.

91

How do you find which entity or query is causing a slow endpoint?

Work from the outside in. Start with the query count for the request. A JDBC proxy or Hibernate statistics gives it, and a count proportional to the result size immediately identifies N+1. Then look at individual query times. One slow query is a different problem from four hundred fast ones, and they need different fixes — an index versus a fetch strategy. For the slow query, get the execution plan from the database with EXPLAIN ANALYZE. That tells you whether it is a missing index, a bad join order, or genuinely large data. For high query counts, log the SQL with a stack trace attached — datasource-proxy can do this — so you can see which line of Java triggered each query. That is what pins down the lazy association being touched. An APM tool with database instrumentation does much of this automatically in production and attributes queries to endpoints. The habit worth building is asserting query counts in integration tests for the important endpoints, so the regression is caught at the commit rather than found in production. Most N+1 problems are introduced by adding a single field access to an existing loop.

92

What is the difference between StatelessSession and a normal Session?

StatelessSession is a stripped-down API with no persistence context. It has no first-level cache, no dirty checking, no cascading, no lazy loading, no lifecycle callbacks and no second-level cache interaction. Operations are immediate: insert, update and delete execute straight away rather than being queued and flushed. The purpose is bulk processing. Because nothing is retained, memory stays flat regardless of how many rows you process — no accumulating managed entities, no snapshots. That removes the need for periodic flush and clear. The cost is that you lose everything the persistence context provides. Entities returned are always detached. Associations are not populated. You must issue every write explicitly, and cascades do not happen so children must be handled manually. So it is right for a batch job reading and writing large volumes of flat data, and wrong for anything with a rich object graph or business logic that relies on the ORM's lifecycle. In practice, for very large batch work, plain JDBC or a bulk SQL statement is often simpler still. StatelessSession is the middle ground when you want entity mapping without the context.

93

Why is it a problem to put a network call inside a transaction?

Because the transaction holds a database connection and any locks it has acquired for the entire duration of that call. If the external service is slow, your database connection is idle but unavailable. If the service is unresponsive and you have no timeout, the connection is held indefinitely. Under load, the pool exhausts and requests that never touch the external service start failing. So an outage in a third-party API becomes a database connectivity failure for your whole application — a failure mode that is very hard to diagnose from the symptoms. Locks held across the call make it worse, since other transactions block behind it. The correct structure is to do the external call outside the transaction. Read what you need, commit, make the call, then open a second transaction to record the result. Where the state change and the external effect must both happen, use the transactional outbox: write the intent to a table in the same transaction, and have a separate process perform the call and mark it done. That gives atomicity without holding a connection across the network. The same argument applies to any slow operation — file I/O, long computation, waiting on a lock.

94

What indexes does an ORM not create for you, and why does it matter?

Hibernate creates indexes for primary keys and, if it generates DDL, for unique constraints. It does not create indexes for foreign keys, and it does not know about your query patterns at all. The foreign key omission is the significant one. Most databases do not automatically index foreign keys — Oracle and PostgreSQL do not — so a @ManyToOne column is typically unindexed unless you add one. Every query filtering or joining on that column then does a full scan, and deleting a parent row scans the child table to check the constraint. That single missing index is behind a very large share of slow ORM queries. Beyond that, indexes for columns used in WHERE clauses, ORDER BY and composite conditions are entirely your responsibility, and the ORM gives no signal that they are missing — the JPQL looks identical whether the underlying query is instant or scans a million rows. So index design has to come from looking at the actual queries and their execution plans, in a migration script rather than in annotations. @Index in JPA only affects generated DDL, which you should not be using in production anyway.

95

When is an ORM the wrong tool?

For bulk data manipulation, where loading a million entities to update them is absurd compared to one SQL statement. For reporting and analytics, where the queries involve aggregation, window functions and CTEs that JPQL cannot express and that do not map to entities anyway. For read-heavy paths where you want exactly a few columns — a projection is fine, but at some point you are just writing SQL through a more awkward interface. For schemas you do not control, particularly legacy ones with composite keys, unusual conventions and no consistent structure, where mapping fights you constantly. And for anything where the database's specific capabilities are the point — full-text search, geospatial queries, JSON operations. The honest framing is that an ORM is good at what it was designed for: managing the lifecycle of a graph of objects in a transactional domain model. It is not a general-purpose database access layer, and treating it as one produces most ORM complaints. The mature architecture uses both — JPA for the transactional domain, and jOOQ, JdbcTemplate or plain SQL for reporting and bulk work. Choosing per use case rather than per project.

96

What is the difference between hibernate.hbm2ddl.auto values?

none does nothing, which is correct for production alongside migrations. validate compares the schema against the entity mappings at startup and fails if they diverge. This is the right production setting: it catches a missing migration immediately at boot rather than at the first query. update attempts to alter the schema to match the entities. It only adds — never removes or modifies — so the schema drifts and accumulates dead columns, and there is no review, no ordering and no rollback. It is a common cause of environments diverging silently. create drops and recreates the schema at startup, destroying all data. create-drop does that and also drops on shutdown. Both are appropriate for tests against a disposable database and nowhere else. The practical policy is validate in every deployed environment, with Flyway or Liquibase owning the schema, and create-drop in unit tests. The reason validate matters is that it turns a subtle runtime failure — a query referencing a column that does not exist — into a loud startup failure that blocks the deployment. That is exactly where you want to find it.

97

How do you migrate a schema safely with an application running?

Make every change backward compatible, so the old and new application versions can both run against the same schema during the rollout. Adding a nullable column is safe. Adding a non-null column is not, unless you add it nullable, backfill, then add the constraint in a later migration. Renaming is never safe as a single step. The expand-and-contract pattern is the answer: add the new column, write to both from the application, backfill the old data, switch reads to the new column, then remove the old one in a later release. Several deployments, but no downtime and no broken version. Dropping a column requires first removing all references from the application and deploying that, then dropping in a subsequent migration. For large tables, be aware of locking: adding an index or a constraint can lock the table for the duration. PostgreSQL supports CREATE INDEX CONCURRENTLY, and adding a NOT VALID constraint then validating separately avoids a long lock. And every migration should be tested against a production-sized copy, because a statement that runs in a second on test data can lock a table for twenty minutes in production.

98

What is the outbox pattern and why does it come up with JPA?

It solves the dual-write problem: you need to change the database and publish a message, and there is no transaction spanning both. Without it, you either publish first and then fail to commit — so consumers act on something that did not happen — or commit and then fail to publish, losing the event. Both produce inconsistency, and neither is rare under real failure conditions. The outbox writes the event as a row in an outbox table within the same local transaction as the state change. Since it is one transaction against one database, atomicity is guaranteed by the database. A separate process then reads unpublished rows and sends them to the broker, marking them sent. That gives at-least-once delivery, so consumers must be idempotent — the publisher may crash after sending and before marking. The relay can poll the table, or use change data capture with Debezium reading the transaction log, which avoids polling load. It comes up with JPA specifically because the natural instinct is to publish from a service method inside @Transactional, which looks atomic and is not — the message goes out even if the transaction later rolls back.

99

How would you performance-tune an existing Hibernate application?

Measure before changing anything. Start by instrumenting query counts and durations per endpoint, with a JDBC proxy or an APM tool. That immediately separates two very different problems: many fast queries, which is N+1, versus few slow ones, which is a database or indexing issue. Fix N+1 first, since it is usually the biggest win and the easiest. Add fetch joins or entity graphs on the hot paths, set a global batch fetch size as a safety net, and consider DTO projections for read-heavy endpoints. Then check the easy configuration wins: mark read transactions read-only, disable Open Session In View, enable batching with a sequence-based identifier strategy, and set a sensible connection pool size — which usually means making it smaller. Then look at the genuinely slow individual queries with execution plans, and add the indexes the ORM never created, starting with foreign keys. Only after that consider caching, because caching a query that is slow due to a missing index just hides the problem. And add query-count assertions to tests for the paths you fixed, so the improvement does not silently regress next month.

100

What would you tell someone using Hibernate for the first time?

Look at the SQL. Almost every Hibernate problem is obvious in the query log and invisible in the Java code, and the habit of checking what queries an operation actually issues is the single most valuable thing to build early. Map everything lazy and fetch deliberately per query, rather than accepting the eager defaults on to-one associations. Understand the persistence context: that entities are tracked, that changes are written without a save call, and that the context grows for the life of the transaction. Keep transactions short and never put a network call inside one. Use DTO projections for reads and entities for writes. Do not use ddl-auto in production. And accept that the ORM is not transparent. It is a very useful abstraction over a genuinely different model, and the places where it leaks — N+1, lazy initialisation, dirty checking, the impedance mismatch generally — are not bugs to be worked around but consequences of the design. The engineers who use it well are the ones who know what SQL it will produce before they run it, and who are willing to drop to SQL when that is the better tool.

Learn this free with Aria, your AI tutor → AiCanCode.org/learn/interview