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.
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.UnknownPathExceptionmeans “this attribute doesn’t exist on the entity”—it’s a typo detector, not a correctness checker. Ife.department.namehappens 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.
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.
- Going deeper: Jakarta Persistence 3.2 query language specification
- Going deeper: the full HQL chapter, including the pagination and bulk-operation sections this post only summarizes
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.
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. BulkDELETEis 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@PreRemovematter.
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.
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.
- Going deeper: jakarta.persistence.FlushModeType javadoc, the exact “permitted, but not required” wording
- Going deeper: the full HQL chapter covers every test above with the complete assertions
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
- hibernate-demo companion repository and its full HQL chapter
- Hibernate 7 Criteria queries — the type-safe, dynamic-query alternative
- Hibernate 7 named queries — startup validation and Hibernate-specific extras
- Chapter 03, batch inserts — the other half of the IDENTITY-vs-flush-mode story
- Chapter 11, proxies and lazy initialization — the mechanism behind the N+1 in the JOIN section above
- Jakarta Persistence 3.2 specification, query language
No Comments yet!