Hibernate Interview Questions · 2026

Hibernate Interview Questions (2026): Most Asked, With Answers

Hibernate ORM 7.0.0.Final shipped on May 19, 2025, finishing a namespace switch that started with version 6.0: every javax.persistence import became jakarta.persistence, with no backward-compatible bridge, and support for Jakarta Persistence 3.2 came along with it (Hibernate ORM 7.0 release notes). That's a bigger deal on paper than it turns out to be in most interview rooms. A large share of production Java codebases in 2026 are still running Hibernate 5.6 or early 6.x behind Spring Boot 2 or 3, and almost nobody asks a candidate to recite a changelog.

Here's an opinion that might be wrong: I think Hibernate interview questions test annotation memorization more than any other Java topic, and it's the wrong thing to test. Knowing that @OneToMany defaults to lazy doesn't tell an interviewer whether you can debug a LazyInitializationException at 11pm during an on-call shift. The candidates who actually clear a senior round are the ones who can explain why a query fired thirteen times instead of once, not the ones who can list every @GeneratedValue strategy from memory.

This page covers Hibernate interview questions across nine areas: what an ORM actually buys you, entity mapping and annotations, Session versus SessionFactory, the four lifecycle states an entity moves through, lazy versus eager loading, the N+1 problem and how teams actually fix it, first- and second-level caching, HQL/Criteria/native SQL, and transactions plus the real difference between Hibernate and JPA. Java is still used by 29.6 percent of professional developers according to the 2025 Stack Overflow Developer Survey, and a large share of that Java runs on top of an ORM, usually this one (Stack Overflow, 2025).

52Questions
Lifecycle & N+1Core Topic
Java, HQL & ConfigFormat
javax to jakarta since 6.0Namespace

Object-relational mapping: what Hibernate actually buys you

Two warm-up questions. Short section, but a shaky answer here still sets a bad tone for everything after it.

Easy questions

15

It maps Java classes to database tables and Java objects to rows, then generates the SQL for you, so you write against Order and Customer objects instead of hand-rolling every INSERT, UPDATE, and SELECT in JDBC. It also tracks each object's state and its changes, so updating a field on a managed entity is enough, you don't call an explicit save afterward.

The trade-off is control. You give up some say over exactly what SQL runs and when, and that gap between "what I wrote" and "what SQL actually fired" is where most Hibernate interview questions live, N+1 queries and unexpected eager fetches both come from that same gap.

@Entity, a no-arg constructor (Hibernate builds instances via reflection, so it needs one even if you never call it directly), and an @Id field. @Table is optional, without it Hibernate names the table after the class itself.

One detail people skip: if you're relying on Hibernate's default proxy-based lazy loading, avoid marking the class or its accessor methods final, since the proxy is a generated subclass. Build-time bytecode enhancement relaxes that requirement, but plenty of shops still run the default reflection-based proxying.

SessionFactory gets built once, usually at application startup, from your entity mappings and configuration. Building it is expensive, parsing annotations, assembling a metamodel, so almost every app builds exactly one and keeps it alive for the process's whole lifetime.

Session is cheap and short-lived by comparison. It represents one unit of work, a single conversation with the database, wraps a JDBC connection, and holds the first-level cache. In a typical Spring Boot app you never call new SessionFactory() yourself, Spring builds and owns it, and hands you an EntityManager you can unwrap into a native Session when you need Hibernate-specific features.

@ManyToOne and @OneToOne default to FetchType.EAGER per the JPA spec. @OneToMany and @ManyToMany default to FetchType.LAZY. In practice, a lot of teams flip @ManyToOne to lazy explicitly too, since the spec default quietly pulls in a full parent row every time you load a child, even on code paths that never touch that parent.

It's the Session's own persistence context. Every entity the Session has loaded or saved in the current unit of work lives there, keyed by id. It's what guarantees session.get(Order.class, 5L) called twice in the same Session returns the exact same Java object reference, not two separate copies.

No, you can't turn it off, it's fundamental to how Hibernate tracks dirty state for automatic UPDATE generation. The closest you get is clear() or evict(), which forcibly drop entities out of it mid-session.

java
// HQL: entity and field names, database-agnostic
List<Order> a = entityManager.createQuery(
  "select o from Order o where o.customer.email = :email", Order.class)
 .setParameter("email", email)
 .getResultList();

// Native SQL: real table and column names, dialect-specific
List<Order> b = entityManager.createNativeQuery(
  "SELECT * FROM orders WHERE customer_email = ?", Order.class)
 .setParameter(1, email)
 .getResultList();

HQL, and its JPA-standard equivalent JPQL, queries against entity and field names, not table and column names, and Hibernate translates it into whatever SQL dialect the configured database speaks, so the same HQL runs against Postgres or MySQL without a rewrite. Native SQL is the real thing, useful when HQL doesn't expose a database feature you need, or you're calling a stored procedure.

A dialect is the class that translates Hibernate's generic SQL generation into the specific SQL flavor a database understands, things like how LIMIT/OFFSET are written, what the auto-increment syntax looks like, how CASE statements are formed, and what functions exist (NVL versus COALESCE). Get the dialect wrong, say running PostgreSQLDialect against MySQL, and the generated SQL is syntactically invalid for the actual database, usually surfacing as a SQLGrammarException at the worst possible moment.

Modern Hibernate (6 and later) can often auto-detect the dialect from the JDBC connection metadata, but pinning it explicitly in config is still common practice because auto-detection depends on the driver reporting itself correctly, and some cloud-managed databases report a product name that doesn't map cleanly to their real feature set.

@Entity is what tells Hibernate a class is persistent at all, it's what gets the class into the metamodel and eligible for a Session to manage. @Table is purely about naming and physical mapping, letting you specify which table name, schema, and catalog the entity maps to, plus unique constraints and indexes.

If you leave @Table off, Hibernate just uses the unqualified class name as the table name under whatever naming strategy is configured. You'll almost always want @Table anyway once you have more than a handful of entities, mainly to keep table names consistent instead of leaning on convention.

Hibernate builds entity instances through reflection, not by calling business constructors, because when it's hydrating a row from a ResultSet it doesn't know which of your constructors makes sense and needs one uniform way to instantiate any entity. A no-arg constructor (protected is fine, it doesn't need to be public) gives it that hook, and it then populates fields directly or through accessors depending on the access strategy, without running whatever side effects live in other constructors.

The practical trap is putting required validation only in a parameterized constructor and assuming it always runs. Hibernate builds entities through the no-arg constructor and field access, bypassing that validation entirely, so any invariant that actually matters needs to live somewhere that runs on every path, like a @PrePersist callback, not just in a public constructor.

It's the setting that tells Hibernate whether to generate and run DDL automatically based on entity mappings, with values like none, validate, update, create, and create-drop. update compares Hibernate's metamodel to the current schema and issues ALTER statements to close the gap, but it only ever adds, it won't drop a column removed from an entity and it won't rename anything, a rename just looks like an add plus an orphaned old column.

The real danger in production is that it runs automatically on startup with no review step, no diff you can read before it executes, and no rollback path if it guesses wrong on a large table. Most teams settle on ddl-auto=validate paired with a real migration tool like Flyway or Liquibase that owns actual schema changes as versioned, reviewable scripts.

In a bidirectional association, the @ManyToOne side is almost always the owning side because it's the side that physically holds the foreign key column, an Order having a @ManyToOne Customer means the orders table has a customer_id column. The @OneToMany side is the inverse side and has to declare mappedBy pointing back at the field on the owning side, telling Hibernate not to manage the foreign key from there.

Forget mappedBy on the @OneToMany side and Hibernate assumes it's also an owning side, creating a separate join table to manage the relationship, which is rarely what anyone intended. The rule of thumb: whichever table physically has the foreign key column is the owning side, and that's the side whose in-memory state actually gets written.

CascadeType.ALL is shorthand for PERSIST, MERGE, REMOVE, REFRESH, and DETACH all at once, meaning any of those operations performed on the parent get propagated automatically to whatever's in the associated collection. Calling remove() on an Order with CascadeType.ALL on its OrderLine collection deletes every line item along with the order in the same transaction.

It's a reasonable default for genuine parent-child ownership where the child has no independent lifecycle outside the parent, an Order and its OrderLines. It's a bad default for anything shared, like a Customer referencing a Country, because REMOVE cascading there tries to delete rows other entities still legitimately reference.

CascadeType.REMOVE only fires when you explicitly call remove() on the parent entity itself. orphanRemoval=true is narrower but catches a different case, it deletes a child automatically the moment it's taken out of the parent's collection, even if the parent is never deleted at all. If an Order removes a line from orders.getLines() and that line isn't reassigned elsewhere, Hibernate issues a DELETE for it on the next flush without remove() ever being called directly.

They're commonly used together on a true composition relationship, since CascadeType.REMOVE alone won't clean up a child simply detached from the collection, and orphanRemoval alone won't catch deleting the parent outright without touching the collection.

A composite key is a primary key made of more than one column, common in join tables or naturally keyed reference data. Hibernate gives you two ways to map it: @EmbeddedId, where a separate @Embeddable class holds the key fields and a single instance of it is the entity's @Id, or @IdClass, where the key fields stay directly on the entity but point at a separate plain class mirroring those field names and types.

@EmbeddedId is generally the cleaner choice because the key fields are grouped in one reusable object you can query directly against. @IdClass is more useful when retrofitting a composite key onto an entity where the fields are already flat and you don't want to restructure the class.

flush() synchronizes the in-memory state of the persistence context with the database, it's the point where Hibernate actually generates and sends the queued INSERT, UPDATE, and DELETE statements. It does not end the transaction, and none of it is durable yet, another connection in a different transaction still won't see those changes because they haven't been committed.

commit() is a transaction boundary operation, it triggers a flush first if one hasn't already happened, then tells the database to make everything permanent and visible elsewhere. The distinction matters most when debugging why a query inside the same transaction isn't seeing an entity just persisted, that's a flush timing problem, not a commit problem.

Medium questions

25

For a CRUD-heavy app with a real domain model and object graphs worth navigating, yes. For a reporting-heavy or analytics-adjacent service where most work is really just SQL with extra steps, an ORM's abstraction can get in the way, and I've seen teams pick Spring Data JDBC or jOOQ specifically to sidestep proxy and lazy-loading behavior they'd rather not reason about.

I don't have good numbers on how common that switch actually is outside anecdotes from a handful of teams. But it's a fair question for a senior interview to ask the other way around: not "do you know Hibernate," but "when would you deliberately not reach for it."

IDENTITY. Hibernate can't know the generated primary key until the row is actually inserted, since an identity column is assigned by the database on insert, so there's nothing to queue and batch, each row has to go in, and its key has to come back, one at a time.

SEQUENCE doesn't have that problem. Hibernate can pre-allocate a block of ids from the database sequence before any insert runs, so those inserts really can be batched together. If a team cares about bulk-insert performance, that's usually the deciding factor between the two, not id-generation style preference.

get() hits the database immediately and returns null if nothing matches. load() doesn't touch the database at call time at all, it hands back a lazy proxy, and only fires a query the first time you actually read a field off it.

java
Order o1 = session.get(Order.class, 5L);  // SELECT fires now, o1 is null if no row exists
Order o2 = session.load(Order.class, 5L); // no SELECT yet, o2 is a proxy
o2.getStatus();              // SELECT fires here, throws if row 5 doesn't exist

get() fits "does this exist." load() fits "I'm confident this exists and just need a reference," like setting a foreign key without paying for a full row fetch you're never going to read.

Transient: a plain new Order(). No Session knows about it, no row exists for it. Persistent, also called managed: the Session has it in its first-level cache, and Hibernate tracks every field change, issuing an UPDATE automatically at flush time with no explicit save call needed once it's in this state.

Detached: it was persistent, but the Session that loaded it closed, or you called evict() or clear(). Hibernate stops tracking it, and changes you make to it now go nowhere until you reattach it somehow. Removed: JPA's fourth state, scheduled for deletion, it exists between calling remove() and the transaction actually committing the DELETE.

It fires when code tries to initialize a lazy proxy, touching a field, calling a getter that isn't the id, after the Session that owns it has already closed. Classic case: a controller loads an entity, a service layer returns it, the Session closes at the end of the transaction, and a view template then tries to render order.getCustomer().getName() and blows up.

Open Session in View keeps the Session open through rendering specifically to dodge this. Spring Boot ships it enabled by default, spring.jpa.open-in-view defaults to true, and logs a startup warning if you never set it explicitly. My honest opinion: OSIV trades one visible exception for a quieter, worse problem, it lets N+1 queries fire silently in the view layer where nobody's profiling, and it holds a database connection open for an entire request instead of just the transactional part of it.

java
List<Order> orders = session.createQuery("from Order", Order.class).list(); // 1 query
for (Order o : orders) {
  System.out.println(o.getCustomer().getName()); // 1 query per order, lazily
}

One query loads the 12 orders. Then, because customer is a lazy @ManyToOne in this example, each loop iteration fires its own SELECT to fetch that order's customer, 12 more queries, 13 total instead of 1. It's invisible in a unit test seeded with three rows and very visible in production with 40,000.

java
// JOIN FETCH: pulls the association into the same query
List<Order> orders = session.createQuery(
  "select o from Order o join fetch o.customer", Order.class).list();

// @BatchSize: batches the missing associations instead of joining
@Entity
@BatchSize(size = 25)
public class Customer { }

JOIN FETCH pulls the association in through a SQL join, best when you already know you'll need it for every row in the result set. @BatchSize is Hibernate-specific, and instead of one query per missing association, it batches them, fetch 25 customer ids in one WHERE id IN (...) query instead of 25 separate round trips.

It doesn't get you to one query, it gets you to roughly N divided by 25, which is still a huge win over N and doesn't risk a join blowing up the result set with duplicate rows the way JOIN FETCH can on a one-to-many. There's also a global hibernate.default_batch_fetch_size setting that applies batching cluster-wide without needing the annotation on every entity.

It moves the problem, it doesn't fix it. Eager-load every association and you've traded N+1 queries for one query that joins in data most requests never asked for, and eager-fetching two separate collections in the same query can throw MultipleBagFetchException or produce a Cartesian-product row explosion, depending on version and collection type.

I'd rather see a team default everything to lazy and add JOIN FETCH or an @EntityGraph deliberately on the two or three query paths that actually need it, than flip the global default to eager and hope nobody adds a heavy collection to that entity later.

Three things: a cache provider on the classpath (Ehcache, Infinispan, and Caffeine via a JCache wrapper are the common ones), hibernate.cache.use_second_level_cache=true, and @Cache(usage = CacheConcurrencyStrategy.READ_WRITE), or another strategy, on each entity you actually want cached. It's opt-in per entity, not global, even once the machinery is turned on.

The mistake I see most: enabling it and caching everything, including entities that get updated constantly. A cache that invalidates on nearly every write buys you nothing and adds real overhead. It earns its keep on read-heavy, rarely-changing data, lookup tables, country lists, plan tiers, that kind of thing.

java
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Order> cq = cb.createQuery(Order.class);
Root<Order> order = cq.from(Order.class);

List<Predicate> filters = new ArrayList<>();
if (status != null) filters.add(cb.equal(order.get("status"), status));
if (minTotal != null) filters.add(cb.greaterThanOrEqualTo(order.get("total"), minTotal));

cq.where(cb.and(filters.toArray(new Predicate[0])));

Dynamic queries, mainly, a search screen with six optional filters where hand-building the right HQL string for every combination gets ugly fast. Criteria API builds the query programmatically and, paired with generated metamodel classes, type-safely, so a typo in a field name fails at compile time instead of surfacing as a runtime QuerySyntaxException.

For anything simpler than that, I don't think Criteria's verbosity is worth it, and plenty of teams reach for Spring Data JPA Specifications or querydsl for the same dynamic-filter problem instead, since both sit on the same JPA metamodel with noticeably less boilerplate.

Two reasons. First, Hibernate's default flush mode is AUTO, which flushes pending dirty state before a query executes, so without a clear transaction boundary you can get surprising ordering, an UPDATE firing in the middle of what looked like a read-only method. Second, without an active transaction, some connection pools and drivers run in auto-commit mode, one round trip per statement instead of a coherent unit of work, which is both slower and gives you no consistent snapshot across multiple queries in the same method.

@Transactional(readOnly = true) in Spring doesn't skip the transaction, it hints the driver and skips some dirty-checking overhead, but there's still a transaction wrapping the call.

LockModeType.PESSIMISTIC_WRITE translates to a SELECT... FOR UPDATE, dialect permitting, and blocks other transactions from touching those rows until yours commits or rolls back.

I'd use it sparingly, mostly on genuinely high-contention single rows, an inventory count during a flash sale, a wallet balance, somewhere losing an optimistic race and retrying isn't good enough because two competing writes could both legitimately want to succeed and only one can. For most CRUD paths where conflicts are rare, optimistic locking with a retry is less code and doesn't tie up a database connection holding a lock while some unrelated part of the request runs slow.

JPA is a specification, part of Jakarta EE, a set of interfaces and annotations (jakarta.persistence.*) with no implementation of its own. Hibernate is one implementation of that spec (EclipseLink and OpenJPA are others), and it predates JPA by roughly five years, Gavin King's first Hibernate release was 2001, JPA 1.0 only arrived in 2006 as part of EJB 3.0, and it borrowed heavily from ideas Hibernate had already shipped.

Day to day, most code you write against EntityManager, @Entity, @OneToMany is portable JPA. The moment you reach for something like @Where for a permanent filter clause, @BatchSize, or Envers for audit history, none of which have a JPA equivalent, you're writing Hibernate-specific code, and swapping providers later would mean rewriting that part.

The mistake is setting values on both sides of a bidirectional association but only actually writing to the inverse (@OneToMany mappedBy) side. Add a line item to order.getLines() but never call line.setOrder(order), and Hibernate has no idea the two are related, because the owning side, the one with the actual foreign key column, is what gets persisted, and that field is still null. The in-memory object graph looking correct is irrelevant if the database doesn't agree with it.

The second common variant is forgetting mappedBy entirely and declaring @OneToMany as if it also owns the relationship. Instead of one foreign key column on the child table, Hibernate creates a separate join table to track the association, giving you two mechanisms representing one relationship. The fix in both cases is discipline, always update both sides together, usually through a helper method on the parent that does this.lines.add(line) and line.setOrder(this) in one call.

When Hibernate loads an entity into the persistence context, it keeps a snapshot of its state at load time, essentially a copy of every field value. At flush time it walks every managed entity and compares current field values against that snapshot, and for anything that changed it generates the corresponding UPDATE. No manual setDirty() call, no explicit tracking, it's just an automatic diff against the loaded snapshot.

The cost is memory and CPU scaling with how many entities are managed and how wide they are. A read-heavy service loading thousands of large entities into one long-lived persistence context is holding two copies of every field in memory purely for a comparison it will never use, since it's never going to write. That's exactly the case for a read-only query hint, which skips the snapshot and dirty check entirely, or a StatelessSession that skips both altogether.

JOIN FETCH is written directly into the HQL or JPQL query, static and tied to that one query, so a different fetch shape for the same entity elsewhere means a new query. @EntityGraph is a separate, composable description of what to fetch eagerly, defined as a named graph on the entity or built dynamically at query time, and it attaches to a find() call or a query without rewriting the query itself.

The practical reason to reach for entity graphs is when the same repository method needs different fetch depths at different call sites, an order summary screen only needing the customer name versus an order detail screen needing customer, lines, and each line's product. Rather than duplicating query strings, different graphs attach to the same underlying fetch. JOIN FETCH stays simpler and more predictable for a single well-known query shape, which is why people usually reach for it first.

A StatelessSession is a lower-level API that skips almost everything a normal Session gives you, no first-level cache, no automatic dirty checking, no cascading, no automatic flush-before-query. Every insert, update, and delete executes immediately and directly, with nothing lingering in memory waiting to be reconciled at flush time.

That makes it the right tool for bulk operations, a nightly job processing a million rows, a migration script, an export, where holding every processed entity in a growing persistence context would eventually exhaust the heap. The tradeoff is losing the conveniences, no cascading means managing related entities yourself, and no first-level cache means repeated get() calls for the same id hit the database every time.

hibernate.jdbc.batch_size, commonly 20 to 50, tells Hibernate to group that many INSERT or UPDATE statements into a single JDBC batch instead of sending them one at a time, cutting round trips dramatically. You generally also want hibernate.order_inserts and hibernate.order_updates set true, otherwise statements batch in the order operations happened rather than grouping by table, and mixed entity types in a transaction break batches into smaller chunks than needed.

The setting that quietly disables all of this is using an IDENTITY generation strategy for the primary key. Because IDENTITY requires the database to assign the key at insert time, Hibernate has to execute each insert immediately to learn the generated id, so it can't hold statements in a batch waiting to fire together. Switching to SEQUENCE with a reasonably sized allocation is the usual fix when batching actually matters for write-heavy tables.

A transaction-scoped persistence context, the default in a typical Spring/JPA setup, lives and dies with the transaction. Every managed entity becomes detached the instant the transaction commits or rolls back, and a brand new persistence context starts on the next transaction. An extended persistence context survives across multiple transactions, typically tied to a stateful component that holds onto an explicitly managed EntityManager across several method calls.

Where it bites you: with a transaction-scoped context, code assuming an entity is still managed after the surrounding transaction ended, a lazy collection accessed in a view layer after the service method returned, throws LazyInitializationException because that entity is detached with no Session left to fetch through. Extended contexts avoid that specific pain but introduce their own problem, entities accumulate across transactions with nothing forcing a clear or detach, the same unbounded growth issue that shows up as slow, memory-hungry long sessions.

@Converter with an AttributeConverter implementation (parameterized on the Java type and the database column type) controls exactly how a Java field type translates to and from the database column type, on the way in through convertToDatabaseColumn and the way out through convertToEntityAttribute. It's the mechanism for whenever the built-in JPA type mapping doesn't do what you want, or the domain type isn't something JPA knows about at all.

A concrete example is storing a Money value object as a single formatted string column, or storing an enum as a short database code rather than its name for a legacy schema that can't change. Another common one is encrypting a sensitive field transparently, converting plaintext to ciphertext on write and back on read, so the rest of the codebase works with the plain Java type and never touches the encryption logic. Set autoApply=true to apply it automatically to every field of that Java type, otherwise annotate each field explicitly with @Convert.

READ_ONLY is for data that never changes after insert, reference tables and lookup values, it's the fastest strategy since there's no invalidation logic to worry about, an update actually throws. READ_WRITE uses a soft-lock scheme around updates to prevent another transaction from reading stale cached data mid-write, giving reasonably strong consistency without needing full transactional support from the cache provider. NONSTRICT_READ_WRITE doesn't lock at all, it just evicts on update, leaving a real window where a concurrent read still sees the stale value, it's for data where an occasional stale read for a few milliseconds genuinely doesn't matter.

TRANSACTIONAL ties the cache into the actual JTA transaction for full ACID guarantees, but needs a cache provider that supports it and is noticeably more expensive. Most teams use READ_ONLY for genuinely static reference data and READ_WRITE for everything else cacheable, reaching for NONSTRICT_READ_WRITE only after measuring that READ_WRITE's locking is an actual problem.

ORDINAL stores the enum's position in its declaration as an integer, compact but tied to the exact order the constants are declared in the Java source. Insert a new constant in the middle of the enum, say adding PENDING_REVIEW between PENDING and APPROVED, and every ordinal after that insertion point silently shifts, so every row already in the database now maps to the wrong logical value with no error, just quietly wrong data.

STRING stores the constant's name as text, immune to reordering, at the cost of a few more bytes per row and a very small reliance on constant names never changing, renaming is still a migration, just a rarer and more deliberate one than reordering. Given how easy it is to forget the ordinal constraint until it's already broken production, STRING is the safer default almost everywhere except genuinely append-only enums.

@NaturalId marks a property, or set of properties, as a business key, an ISBN, an email address, an account number, that uniquely identifies a row in a way meaningful to the domain, separate from the surrogate @Id. It buys a dedicated API, session.byNaturalId(Entity.class).using("email", value).load(), which participates in the first and second-level cache the same way loading by primary key does.

The practical difference from a plain WHERE-clause query is caching and intent. A regular query with a WHERE clause needs its own cache region management, while byNaturalId lookups can resolve straight from the second-level cache using a natural-id-to-primary-key mapping Hibernate maintains internally, without a round trip, once that mapping's been cached. It also documents intent, anyone reading the entity knows which field is the real-world identity versus a database implementation detail.

A filter is a parameterized restriction defined once at the entity level with @FilterDef and @Filter, then enabled per-Session at runtime with session.enableFilter("name").setParameter(...). Once enabled, Hibernate automatically appends that condition to every query against the filtered entity for as long as it's active, without rewriting a single query.

The textbook use case is soft deletes, entities carry a deleted flag instead of ever running an actual DELETE, and a filter appending deleted = false gets enabled by default so every normal query only sees non-deleted rows, while an admin tool that needs everything simply doesn't enable it for its session. It's also common for tenant scoping in shared-schema multi-tenant apps. The catch is filters are opt-in per session, easy to forget to enable, and native SQL queries don't go through the filter mechanism at all, so a raw SQL report can quietly leak rows the filter was meant to hide.

setFirstResult(offset).setMaxResults(pageSize) translates into an OFFSET/LIMIT at the SQL level, and the database still has to scan and discard every row before the offset to reach the page actually wanted. On page 2 that's nothing, on page 5,000 of a ten-million-row table, the database reads and throws away millions of rows just to hand back the next twenty, and the cost grows linearly with how deep you page.

The other problem is correctness under concurrent writes, if rows are being inserted or deleted between page loads, offset 100 can mean a different set of rows the second time it's asked for, so a user paging through can see the same row twice or skip one. The usual fix is keyset pagination, ordering by a stable indexed column and asking for the rows after a specific value instead of a position, turning the query into an indexed range scan that stays stable regardless of concurrent writes.

Hard questions

12

No, and this trips people up constantly because the word does double duty. jakarta.persistence.Transient marks a field Hibernate should ignore entirely, no column, no persistence, nothing to do with an entity's lifecycle at all. "Transient" as a lifecycle state describes a brand-new object that simply hasn't been associated with a Session yet, regardless of whether any of its fields happen to carry that annotation.

You can have a fully transient-state entity where zero fields use @Transient, and a persistent-state entity where several fields do. They're unrelated concepts that share a name, and an interviewer bringing this up is usually checking whether you've actually been confused by it before, not whether you memorized a definition.

No, and this is one of those facts everyone can recite without being able to explain the failure mode. A Session isn't synchronized internally, so two threads calling methods on the same one concurrently can corrupt its internal state, the first-level cache map, the JDBC connection's cursor state, and it usually doesn't fail loudly. It produces wrong data or a confusing exception three calls later, somewhere that has nothing obviously to do with the actual race.

The fix is boring and it works: one Session per request or thread, never pass a Session or EntityManager across a thread boundary. Spring's request-scoped EntityManager already handles this for you in a typical web app, which is exactly why most developers never see the failure mode firsthand.

java
Order o = new Order();           // transient

Long id = (Long) session.save(o);     // Hibernate-native, returns the id directly
session.persist(o);            // JPA-spec method, void, no id returned here

Order detached =...;           // came from a previous, now-closed session
Order managed = session.merge(detached);  // copies detached's state onto a managed copy
// 'detached' is still detached after this call. 'managed' is the tracked one.

save() is Hibernate's older, native method, it returns the generated identifier right away. persist() is the JPA-standard equivalent, returns nothing, and per spec only guarantees the insert happens at or before commit, not necessarily the instant you call it. Both only work on transient entities.

merge() is the odd one out, built specifically for detached entities. It doesn't attach the object you passed in, it copies that object's state onto either an existing managed entity with the same id or a freshly loaded one, and hands that copy back to you. Keep modifying the object you originally passed to merge(), and Hibernate never notices.

No, and this catches people who assume merge() gets the same fine-grained dirty checking a persistent entity gets for free. It doesn't diff field-by-field against what's in the database. It loads the current managed row and copies every field from your detached object onto it, whatever you changed and whatever you didn't touch.

If two different requests detach the same row, each mutate a different field, and both call merge() back without re-reading fresh data first, the second merge silently overwrites the first request's change with a value it never even saw. That's exactly the concurrency risk @Version exists to catch, covered later on this page.

java
@Transactional(readOnly = true)
public OrderDto getOrder(Long id) {
  Order order = orderRepository.findById(id).orElseThrow();
  return new OrderDto(order); // constructor reads order.getCustomer().getName()
}

Inside this method, the whole thing runs within the transaction, so the Session is still open when the DTO constructor touches the lazy customer association. The extra SELECT fires and everything resolves before the method returns.

Return the raw Order entity instead and build the DTO one layer up, outside @Transactional, or push that DTO construction onto a background thread, and the exact same line throws. Same code, different result, purely because of where the Session boundary happens to sit.

It's a separate, off-by-default layer that caches the list of entity ids a specific query, with specific parameter values, returned, not the entity data itself. Resolving those ids still leans on the second-level entity cache being warm. You turn it on with hibernate.cache.use_query_cache=true globally plus .setCacheable(true) per query.

I'd skip it on most tables. Hibernate tracks a last-modified timestamp per table, and any write to a table touched by a cached query invalidates every cached result for that table, not just the row that changed. On a table with even moderate write traffic, you can end up doing the invalidation bookkeeping on nearly every request without getting many cache hits back for it.

It's gotten genuinely better. Hibernate 6 rewrote the query engine around a new semantic query model and closed a lot of the gap, window functions and common table expressions are supported in HQL now in ways they weren't in Hibernate 5.

I still wouldn't lean on that as a default interview answer. Plenty of production codebases are still on 5.6 or early 6.x, where the support is thinner or missing entirely, so "it depends on the version" is a more honest answer than confidently claiming full parity with native SQL.

java
@Entity
public class Order {
  @Id @GeneratedValue
  private Long id;

  @Version
  private int version;

  private String status;
}
// Hibernate generates roughly:
// UPDATE orders SET status=?, version=? WHERE id=? AND version=?

Every UPDATE Hibernate generates for a @Version entity includes the current version value in the WHERE clause and bumps it by one in the SET clause. If another transaction already updated that row (and its version) since you loaded it, your WHERE clause matches zero rows, and Hibernate throws OptimisticLockException instead of silently overwriting someone else's change.

It's cheap, no locks held while you're reading, but it only catches the conflict at write time, so the calling code needs a retry path. It's not free correctness, just cheaper correctness than holding a lock the whole time.

Every entity persisted, and every entity loaded, gets registered in the persistence context and stays there for the life of the Session, along with its dirty-checking snapshot. In a normal request-scoped transaction that's a handful of entities and irrelevant, but in a batch loop processing a million rows inside one long-lived Session, the persistence context keeps growing, tens of thousands then hundreds of thousands of entities, each with a full snapshot copy, never released because nothing ever tells Hibernate it can forget them. Eventually the heap can't hold it, and it's rarely obvious from the stack trace alone that it's a Hibernate accumulation problem rather than a plain application leak.

The fix is periodically flushing and clearing the Session inside the loop, flushing and calling session.clear() every batch_size rows, which writes pending changes to the database and then actually evicts everything from the persistence context so it can be garbage collected. For genuinely bulk jobs, switching that loop to a StatelessSession sidesteps the problem entirely since it never builds a persistence context in the first place, at the cost of cascading and automatic dirty checking, which for a straightforward batch load usually isn't needed anyway.

The most common real cause is something writing to the database that isn't going through Hibernate at all, an ETL job, a reporting tool, an admin script running raw SQL directly, or a different application sharing the same schema. Hibernate's cache invalidation only fires in response to changes it made itself, it has no way to know a row changed if the write happened outside its own Session and transaction lifecycle, so the cache keeps serving the last value it knew about, confidently, because nothing told it otherwise.

The second common cause, especially with more than one application instance, is a cache provider that isn't actually distributed, an in-process configuration where each instance has its own independent cache with no coordination. Instance A updates a row and correctly evicts its own local cache entry, but instance B, which serves the stale read, never heard about the change and keeps its old copy until it separately expires. The fix for the first case is routing all writes through Hibernate, even the batch and admin ones, or deliberately bypassing the cache region for tables known to be touched externally. The fix for the second is a genuinely distributed cache so an eviction on one node is visible to all of them, not just the one that triggered it.

Hibernate's default flush mode, AUTO, flushes the persistence context before executing a JPQL or HQL query if, and only if, Hibernate can tell that query might be affected by pending changes, based on parsing the query and comparing it against entities it knows are dirty. Native SQL queries are opaque strings as far as Hibernate is concerned, it doesn't parse them to figure out which tables they touch, so by default it does not auto-flush before running one unless explicitly told which entities the native query is synchronized with.

The result is exactly this bug, an entity is modified, that change sits in the persistence context unflushed, and a native query in the same transaction reads directly from the database and sees the old value because the pending UPDATE hasn't actually been sent yet. The fix is calling flush() manually right before the native query, or registering the affected entity classes on the native query via addSynchronizedEntityClass, telling Hibernate explicitly to flush anything touching that table first. It's a sharp edge specifically because it's invisible in code review, the native query looks completely correct in isolation, and only misbehaves combined with a specific ordering of operations earlier in the same transaction.

An uninitialized lazy association isn't the real target object, it's a runtime-generated proxy, or with bytecode-enhanced lazy loading a placeholder, that holds a reference back to the Session that created it so it can fetch the real data the first time something touches it. Serialize that entity and either the Session reference fails outright since it was never designed to survive a JVM boundary, often surfacing as a serialization error on some internal Hibernate class, or the placeholder state serializes successfully, ships to another process, and the moment code there touches the lazy field it throws LazyInitializationException because the Session it needs doesn't exist on that side at all.

This shows up most often in clustered web apps replicating HTTP session state across nodes, or pushing an entity straight into a distributed cache without thinking about it. The fix is never letting a lazy-loaded entity cross that boundary in its Hibernate-managed form, initialize what's needed explicitly before serialization with Hibernate.initialize(entity.getAssociation()), or better, map the entity to a plain DTO holding only the data actually needed and serialize that instead. DTOs also sidestep a second, quieter problem, that serializing full entity graphs across a wire tends to pull in far more data than the receiving side ever needed.

How to prepare for a Hibernate interview in 2026

Skip the annotation flashcards. Spin up a small Spring Boot project with two or three related entities (Order, Customer, LineItem is enough), seed it with a few hundred rows, and turn on hibernate.show_sql alongside a query counter, p6spy works, or just eyeball the console. Write a loop that touches a lazy association, watch the extra queries show up one by one, then fix it with JOIN FETCH and watch the count drop to one. Do the same with @Version: open two transactions in two terminal windows, update the same row from both, and actually watch OptimisticLockException happen instead of reading about it.

Across mock interviews run through LastRoundAI tagged Java or backend, the entity-lifecycle question (transient, persistent, detached, removed, plus save versus persist versus merge) trips up more candidates than the N+1 question does, even though N+1 gets more attention in prep guides and blog posts. My guess is that N+1 has a clean, visible symptom, extra queries sitting right there in a log, while lifecycle state is invisible until something goes wrong in a way that's hard to trace back to "oh, that entity was already detached." We don't have a clean percentage to put on that pattern, only that it comes up often enough across sessions to flag here.

Practice on live code, not last night's flashcards

Reading an answer is not the same as defending it once an interviewer changes one detail on you, makes the association bidirectional, swaps @OneToMany for @ManyToMany, asks what happens if you remove @Version. LastRoundAI's mock interview mode runs backend and Java rounds with follow-up questions that adapt to what you actually said instead of a fixed script, and the free plan includes 15 credits a month that reset monthly rather than piling up unused. Starter is $19/mo if fifteen sessions isn't enough runway some months.

If the harder part right now is finding enough backend or full-stack roles that actually list Hibernate or JPA experience, rather than passing the interview once you land one, Auto-Apply queues tailored applications for your review, 10 a month on the free plan, up to 400 a month on the Ultimate plan, and nothing goes out until you approve it.

Questions about either product go to contact@lastroundai.com. That's the only inbox we check.

How this list was built

Worth being straight about where these questions come from, because plenty of pages in this category are not. The set was compiled from a research pass across official documentation, vendor release notes, published engineering writing and public discussion of hiring processes, then cross-checked against the current version of each technology so nothing here describes behaviour that has since changed.

What that means in practice: these are the questions the material supports as reasonable and current for this role, not a transcript of any one company's loop. We have not sat in on your interview and we are not going to claim we have. Treat the list as well-sourced preparation rather than a leaked question bank, and expect your panel to phrase things their own way.

If you spot something out of date, tell us at contact@lastroundai.com and we will fix it.

Frequently asked questions

Should I memorise Hibernate syntax for the interview?

Rarely worth it. Most interviewers care that you know what to reach for and why, and will not fail you for forgetting an exact flag. Being confidently wrong about behaviour costs far more than admitting you would check the documentation.

What is the most common mistake in Hibernate interviews?

Answering the question that was asked and stopping there. The strongest candidates add the trade-off or the failure mode without being prompted, which is what signals real use rather than revision.

How long does it take to prepare for a Hibernate interview?

If you already work with Hibernate day to day, a focused week on the areas you avoid in practice is usually enough. Coming in cold, expect three to four weeks. The gap is rarely knowledge; it is being able to explain something you normally just use.

What Hibernate topics come up most often?

Interviewers concentrate on the parts that cause production incidents rather than the parts that are pleasant to learn. Expect the fundamentals to be assumed and the follow-up questions to sit one layer below what a tutorial covers.

Leave a Reply

Your email address will not be published. Required fields are marked *