Skip to main content

The Ultimate Guide to Hibernate Query Language (HQL) in Hibernate 7

HQL queries your domain model, not your database schema — and that single shift explains why a typo in a field name fails instantly, why JOIN alone doesn’t prevent an N+1, and why a dirty change isn’t always visible the moment you expect. This rewrite covers the smallest working query, the JOIN vs JOIN FETCH trap measured in real statement counts, bulk UPDATE/DELETE bypassing the persistence context, and what each flush mode actually suppresses — including why GenerationType.IDENTITY defeats an INSERT-based test of flush-mode behavior. Every code block and output is linked to a real, passing JUnit test and a captured transcript in the companion repository.

You’ve mapped your entities. You know SELECT * FROM employees WHERE status = 'ACTIVE' in plain SQL. So why does Hibernate hand you a query language that looks almost—but not quite—like SQL, and why does it matter that it isn’t?

The short answer: HQL (Hibernate Query Language, and its JPA-standard subset JPQL) queries your domain model, not your database schema. FROM Employee refers to a Java class, not a table; e.firstName refers to a field, not a column. That one shift explains almost everything else in this article—why some mistakes fail instantly instead of returning wrong data, why a query that looks right can still issue far more SQL than you expect, and why “the query ran” doesn’t always mean “the change is visible yet.”

Versions used in this article. Hibernate ORM 7.4.5.Final, Jakarta Persistence 3.2.0, Spring Boot 4.1.1, Java 25 (Temurin LTS), H2 2.4.240. Every code block and every output block below links to the exact file or transcript in the companion repository it came from—nothing here is illustrative pseudo-code.

Querying the model, not the table

Here’s the mental model worth having before writing a single query: an HQL query is compiled against your @Entity classes, the same way your Java code is compiled against them. The database schema is Hibernate’s problem to translate to, not yours to reference directly.

Your HQL asks the entity model, not the schema HQL query (what you write) SELECT e FROM Employee e WHERE e.firstName = ‘Ada’ Employee = Java class firstName = Java field Generated SQL (what runs) select * from employee e1_0 where e1_0.first_name=? employee = table first_name = column Write e.first_name instead of e.firstName: UnknownPathException: Could not resolve attribute ‘first_name’ of Employee — fails before any SQL is generated

Because HQL resolves against the entity model, a typo in a field name fails immediately and loudly—not as a silent zero-row result, and not as a database error three layers down. That’s the single biggest practical difference from writing raw SQL strings, and it’s worth internalizing before anything else in this article.

The smallest query that works, and the mistake that looks like it should

The smallest HQL query skips SELECT entirely:

List<Employee> all = em.createQuery("FROM Employee", Employee.class).getResultList();

A WHERE clause with a named parameter is the normal shape once you’re filtering:

List<Employee> active = em.createQuery(
        "SELECT e FROM Employee e WHERE e.status = :status", Employee.class)
    .setParameter("status", "ACTIVE")
    .getResultList();
whereWithNamedParameter: 4 active employees

Source: HqlQueryTest.java. Output: hql-select-and-joins.txt.

Now the classic mistake: writing the database column name (first_name) where the entity field name (firstName) belongs. This is exactly the confusion the diagram above is meant to head off—and it fails exactly as promised:

columnNameInsteadOfFieldName: IllegalArgumentException: org.hibernate.query.sqm.UnknownPathException: Could not resolve attribute 'first_name' of 'com.ankurm.hibernatedemo.query.Employee' [SELECT e FROM Employee e WHERE e.first_name = 'Ada']
Read the exception type, not just its presence. UnknownPathException means “this attribute doesn’t exist on the entity”—it’s a typo detector, not a correctness checker. If e.department.name happens to share a column name with something else on the root entity, HQL still resolves the path correctly; this failure mode only catches names that don’t exist at all.

For a deeper look at catching this class of mistake even earlier—at application startup instead of at the first bad call—see named queries and their startup validation, covered in chapter 14 of the companion repo.

JOIN filters. It does not load.

This is the section worth reading slowest, because it’s where a query that looks completely correct in review turns into a production performance problem. A plain JOIN in HQL exists to filter rows—it does not eagerly populate the association for you to read afterward.

JOIN filters; JOIN FETCH loads JOIN e.department (no FETCH) 1 SELECT for the query e.getDepartment().getName() triggers one more SELECT per distinct department touched Result: 2 statements total LEFT JOIN FETCH e.department 1 SELECT loads employees AND departments together touching department afterward costs nothing extra Result: 1 statement total
List<Employee> withDept = em.createQuery(
        "SELECT e FROM Employee e JOIN e.department d WHERE d.name = :deptName", Employee.class)
    .setParameter("deptName", "Engineering")
    .getResultList();
// 1 statement so far
withDept.forEach(e -> e.getDepartment().getName());
// now more than 1 -- one extra SELECT per distinct department touched
joinWithoutFetch: 1 statements for the query, 2 after touching department

The fix costs one keyword:

em.createQuery("SELECT e FROM Employee e LEFT JOIN FETCH e.department", Employee.class)
    .getResultList();
joinFetch: 1 statement total, 5 rows

Source: HqlQueryTest.java. Output: hql-select-and-joins.txt.

This is arguably the most common Hibernate performance bug that ships to production, and the reason is that it reads as correct: the JOIN is right there in the query text, so it looks like the association is being loaded. It isn’t. The distinction only shows up under a statement counter or a profiler, which is exactly why the test above asserts Statistics.getPrepareStatementCount() rather than trusting the query text. Two paragraphs deeper on when eager beats lazy at the mapping level: chapter 12, association mappings; and the mechanism underneath what getDepartment() actually returns before it’s touched is in chapter 11, proxies and lazy initialization.

Aggregates and pagination

COUNT, AVG with GROUP BY, and offset-based paging all read the way you’d expect from SQL:

Long count = em.createQuery("SELECT COUNT(e) FROM Employee e", Long.class).getSingleResult();

List<Object[]> rows = em.createQuery(
        "SELECT d.name, AVG(e.salary) FROM Employee e JOIN e.department d GROUP BY d.name ORDER BY d.name",
        Object[].class).getResultList();
aggregateCount: 5
avgSalaryGroupByDepartment: Engineering -> 95000.0
avgSalaryGroupByDepartment: Marketing -> 71500.0

Both averages were hand-computed from the seed data and asserted to match—not just observed as “a number came back.” Pagination combines setFirstResult (offset) with setMaxResults (limit), and needs an explicit ORDER BY to be deterministic across pages:

em.createQuery("FROM Employee e ORDER BY e.lastName", Employee.class)
    .setFirstResult(0).setMaxResults(2).getResultList();
pagination: page1=[Byron, Hamilton], page2=[Hopper, Johnson]

Source: HqlQueryTest.java. Output: hql-aggregation-paging-bulk.txt.

  • Going deeper: offset-based paging degrades on large tables because the database still scans and discards every skipped row—keyset pagination is the usual fix, and it’s worth reading about before your table grows past a few hundred thousand rows.

Bulk UPDATE and DELETE skip the persistence context entirely

HQL’s UPDATE and DELETE execute as a single SQL statement directly against the database. They do not load matching rows into the persistence context first—and, more surprisingly, they do not update any entity that’s already loaded there.

Bulk UPDATE bypasses already-loaded entities 1. Load Torvalds status = INACTIVE now managed in-memory 2. Bulk UPDATE runs DB row: status = ARCHIVED in-memory object: unchanged 3. Read stale entity loaded.getStatus() still says INACTIVE em.clear() + reload: now sees ARCHIVED
Employee loaded = em.createQuery("SELECT e FROM Employee e WHERE e.lastName = 'Torvalds'", Employee.class)
    .getSingleResult(); // status = INACTIVE, now managed

int updated = em.createQuery("UPDATE Employee e SET e.status = 'ARCHIVED' WHERE e.status = 'INACTIVE'")
    .executeUpdate();
// updated == 1, and the row in the DATABASE now says ARCHIVED
// but `loaded` -- already in the persistence context -- still says INACTIVE
bulkUpdate: updated=1 rows, stale in-memory status=INACTIVE, reloaded status=ARCHIVED

Source: HqlQueryTest.java, output: hql-aggregation-paging-bulk.txt. Only after em.clear() and a fresh find() does the already-loaded entity’s Java field catch up. DELETE is simpler—there’s no lingering in-memory state to go stale, since there’s no entity left:

em.createQuery("DELETE FROM Employee e WHERE e.department.id = :deptId")
    .setParameter("deptId", marketingId).executeUpdate();
bulkDelete: deleted=2 rows, remaining=3

Same source and output files as the UPDATE example above: HqlQueryTest.java / hql-aggregation-paging-bulk.txt.

em.remove() and bulk DELETE are not the same operation. remove() on a managed entity triggers cascades and lifecycle callbacks, one entity at a time. Bulk DELETE is one SQL statement against the database and skips both. Choose deliberately—bulk operations are much faster for large sets, remove() is correct when cascades or @PreRemove matter.

What flush mode actually controls (and what it doesn’t)

This section exists because of a dead end worth admitting to. Proving flush-mode behavior sounds simple: persist something new, set a flush mode, check whether a later query sees it. That approach doesn’t work, and the reason is worth knowing before you try it yourself.

Employee‘s @Id uses GenerationType.IDENTITY, which forces an immediate INSERT on persist()—independent of flush mode entirely—because Hibernate has to round-trip to the database right away to obtain the generated key. Flush mode never gets a chance to defer anything for an IDENTITY insert. (This is the same fact behind chapter 03’s finding that IDENTITY disables JDBC batching—same root cause, different symptom.)

The fix that actually isolates flush-mode behavior: dirty an already-loaded, already-managed entity instead. An UPDATE isn’t tied to id generation, so it cleanly shows what each mode does.

Three flush modes, same dirty UPDATE, three outcomes Default AUTO dirty salary -> auto-flushed before query -> query sees 999999.0 (new value) jakarta.persistence.FlushModeType.COMMIT implementation-defined -> Hibernate 7.4.5 does NOT flush -> query sees 98000.0 (old value) org.hibernate.FlushMode.MANUAL (native, no jakarta equivalent) never auto-flushes -> query sees 92000.0 (old) until an explicit session.flush()
Employee ada = em.createQuery("SELECT e FROM Employee e WHERE e.lastName = 'Byron'", Employee.class).getSingleResult();
ada.setSalary(999_999.0); // dirtied, not explicitly flushed
Double seen = em.createQuery("SELECT e.salary FROM Employee e WHERE e.lastName = 'Byron'", Double.class).getSingleResult();
defaultFlushMode: salary seen by a fresh query after an unflushed dirty change = 999999.0

Source and output for all three flush-mode tests below: HqlQueryTest.java / hql-flush-modes.txt.

jakarta.persistence.FlushModeType.COMMIT‘s own javadoc says a provider is “permitted, but not required” to flush before a query—it’s implementation-defined, not a guarantee. Verified directly: Hibernate 7.4.5 chooses not to flush, so the same dirty change stays invisible:

jakartaCommitFlushMode: salary seen by query under FlushModeType.COMMIT = 98000.0 (pre-update value was 98000.0)

Hibernate’s own native org.hibernate.FlushMode.MANUAL has no jakarta.persistence equivalent, and it’s the one mode that genuinely, unconditionally suppresses auto-flush until an explicit call:

nativeManualFlushMode: before explicit flush=92000.0, after=123123.0

(Same source and output files as above.)

If you’re debugging flush-mode behavior yourself, use an UPDATE, not a persist(). With GenerationType.IDENTITY, a new entity’s INSERT happens immediately regardless of flush mode, and you’ll waste time convinced flush mode is broken when it never got a turn.

FlushMode.MANUAL plus batched explicit flushes is a real, measurable technique for tight write-heavy loops—see chapter 03’s batching numbers—but only for updates, never for anything that also needs a database-generated key back immediately.

HQL, Criteria, or a named query—which one?

HQL is the right default for a fixed, known query—it reads closest to SQL and needs the least ceremony. Reach for the Criteria API when the query has to be built dynamically from optional filters at runtime, where string concatenation becomes genuinely painful. Reach for a named query when you want the exact failure this article opened with—an unresolvable field name—caught at application startup instead of at the first request that hits it.

None of these are mutually exclusive in a real codebase, and none of them replace understanding what a query actually does underneath—which is the whole reason the join and flush-mode sections above measure statement counts and flush timing instead of trusting what the query text implies.

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.