Search endpoints are where string-based HQL starts to hurt. Five optional filters, each present or absent depending on what the user picked, and now you’re conditionally concatenating query text—or building five near-duplicate query strings for every combination. The Criteria API exists specifically for this case: build the query out of Java objects instead of text, so “add this predicate only if the filter is present” becomes an ordinary if statement instead of string surgery.
What this article adds beyond “here’s the API”: a real, compiler-generated static metamodel exercised in actual queries (not hand-typed stand-ins), and a direct comparison against the HQL equivalent for the traps that show up in both.
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.hibernate-jpamodelgenis wired as a compiler annotation-processor path, not a plain dependency—every code block below links to the exact file it came from, and the metamodel classes referenced are real, generated files, confirmed present intarget/generated-sources/annotations/.
Three pieces, assembled instead of written
A Criteria query is built from three collaborating objects: CriteriaBuilder is the factory for predicates and expressions, CriteriaQuery describes the shape of the query, and Root represents the entity being queried—the FROM clause, as an object you can call methods on instead of a string you concatenate into.
The smallest working example fetches everything—equivalent to SELECT * FROM Employee:
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Employee> cr = cb.createQuery(Employee.class);
Root<Employee> root = cr.from(Employee.class);
cr.select(root);
List<Employee> results = em.createQuery(cr).getResultList();
Source: CriteriaQueryTest.java.
String paths compile even when they’re wrong
Predicates are built with CriteriaBuilder methods and combined with cb.and()/cb.or():
Predicate salaryGreater = cb.gt(root.get("salary"), 90_000.0);
Predicate nameLike = cb.like(root.get("lastName"), "%o%");
cr.select(root).where(cb.and(salaryGreater, nameLike)).orderBy(cb.asc(root.get("lastName")));
stringPathPredicates: [Byron, Hopper, Torvalds]
Source: CriteriaQueryTest.java, output: criteria-predicates-and-metamodel.txt.
Worth reading that result carefully: salary > 90000 alone already returns exactly these three people—lastName LIKE '%o%' doesn’t narrow the set any further here, because “Byron”, “Hopper” and “Torvalds” all genuinely contain an “o”. Both predicates are doing real work in general; they just happen to agree on this particular data, and the test asserts the actual combined result rather than assuming which predicate mattered.
root.get("salary") compiles even if salary is misspelled—the mistake only shows up at runtime, the same class of failure HQL has with field names. That’s the entire reason the next section exists.
The static metamodel is real, and it’s compiler-checked
Rewriting the identical query through Employee_, a class generated at build time by hibernate-jpamodelgen:
cr.select(root)
.where(cb.gt(root.get(Employee_.salary), 90_000.0))
.orderBy(cb.asc(root.get(Employee_.lastName)));
staticMetamodel: [Byron, Hopper, Torvalds]
Source: CriteriaQueryTest.java, output: criteria-predicates-and-metamodel.txt. Identical result to the string-path version—proof that Employee_.salary and Employee_.lastName are genuinely wired to the underlying attributes, not just present and unused. Misspell Employee_.salery and the build fails at javac, not three months later when someone renames the field and misses a string literal thirty call sites away.
Joins go through the same generated fields:
Join<Employee, Department> department = root.join(Employee_.department, JoinType.INNER);
cr.select(root).where(cb.equal(department.get(Department_.name), "Engineering"));
joinViaMetamodel: 3 engineering employees
Source: CriteriaQueryTest.java, output: criteria-predicates-and-metamodel.txt.
The setup is one Maven config block, not per-entity work. Wirehibernate-jpamodelgenas anannotationProcessorPathonmaven-compiler-plugin(a plain dependency doesn’t trigger annotation processing)—once that’s in place, every@Entitygets its_-suffixed metamodel class automatically on every build.
- Going deeper: the exact annotationProcessorPaths block in the companion repo’s pom.xml
- Going deeper: hibernate-jpamodelgen on Maven Central
root.join() filters. root.fetch() loads. Same trap as HQL.
This is HQL’s JOIN vs. JOIN FETCH distinction again, in Criteria form, and it’s just as easy to get wrong—root.join() looks like it should load the association, and it doesn’t.
root1.join(Employee_.department, JoinType.INNER);
cr1.select(root1);
// after running and touching e.getDepartment().getName() for every row:
rootJoinVsFetch: join+touch=3 statements, fetch+touch=1 statement
root.fetch() is the fix—note it needs .distinct(true) on the query to avoid duplicate rows when the fetched association is a collection (harmless here since department is @ManyToOne, kept for the habit):
root2.fetch(Employee_.department, JoinType.INNER);
cr2.select(root2).distinct(true);
Source: CriteriaQueryTest.java, output: criteria-predicates-and-metamodel.txt.
JoinandFetchare different interfaces.root.join()returns aJoin,root.fetch()returns aFetch—which is part of why the wrong one is easy to reach for, since your IDE autocompletes either happily. Reach forfetch()specifically when you intend to read the association afterward.
The full mechanism behind why the untouched association still costs a query is the same one covered in the HQL article’s JOIN section and chapter 11 on proxies and lazy initialization.
Aggregation and subqueries—where “obviously correct” is worth double-checking
CriteriaBuilder.avg() against a metamodel path, on a Double-typed query:
CriteriaQuery<Double> avgQuery = cb.createQuery(Double.class);
Root<Employee> avgRoot = avgQuery.from(Employee.class);
avgQuery.select(cb.avg(avgRoot.get(Employee_.salary)));
aggregation: average salary = 85600.0
A correlated-by-value subquery, finding employees above that company-wide average:
Subquery<Double> sub = mainQuery.subquery(Double.class);
Root<Employee> subRoot = sub.from(Employee.class);
sub.select(cb.avg(subRoot.get(Employee_.salary)));
mainQuery.select(empRoot).where(cb.gt(empRoot.get(Employee_.salary), sub));
subquery: above-average earners (avg=85600) = [Byron, Hopper, Torvalds]
Source: CriteriaQueryTest.java, output: criteria-aggregation-and-subquery.txt.
This result is worth reading twice. The seed data has two departments: Engineering (95000, 98000, 92000) and Marketing (72000, 71000). The naive assumption is “only the single highest earner clears the average”—that’s wrong here. The two Marketing salaries pull the company-wide average down to 85600, well below every Engineering salary, so all three Engineering employees clear it, not just the top earner. An earlier draft of this exact test asserted the wrong single-employee result, and it was only caught by computing the average by hand and comparing—a reminder that “above average” queries need the real average computed, not eyeballed.
- Going deeper: the full Criteria chapter covers
cb.or()‘s varargs overload and the operator-precedence confusion it’s easy to fall into
Bulk operations, type-safe
Same bulk-operation semantics as HQL’s UPDATE/DELETE—a single SQL statement, bypassing the persistence context for already-loaded entities—expressed through CriteriaBuilder.createCriteriaUpdate()/createCriteriaDelete() instead of a query string:
CriteriaUpdate<Employee> update = cb.createCriteriaUpdate(Employee.class);
Root<Employee> updRoot = update.from(Employee.class);
update.set(Employee_.salary, cb.prod(updRoot.get(Employee_.salary), 1.1))
.where(cb.equal(updRoot.get(Employee_.department).get(Department_.id), engineeringId));
int updated = em.createQuery(update).executeUpdate();
criteriaUpdate: 3 rows updated, Ada's new salary = 104500.00000000001
Source: CriteriaQueryTest.java, output: criteria-bulk-update-delete.txt. cb.prod(...) is Criteria’s typed multiplication, and the trailing .00000000001 on the result is ordinary double floating-point representation—95000.0 * 1.1 doesn’t land on an exact binary fraction, which is exactly why the test’s assertion uses an offset-based comparison rather than exact equality.
CriteriaDelete<Employee> delete = cb.createCriteriaDelete(Employee.class);
Root<Employee> delRoot = delete.from(Employee.class);
delete.where(cb.isNull(delRoot.get(Employee_.department)));
int deleted = em.createQuery(delete).executeUpdate();
criteriaDelete: deleted=1, remaining=5
Same source and output files as the update example above: CriteriaQueryTest.java / criteria-bulk-update-delete.txt.
CriteriaUpdate/CriteriaDeleteneed their ownRoot. You cannot reuse aRootfrom aCriteriaQuerybuilt earlier in the same method, even against the same entity type—update.from(...)/delete.from(...)create a distinct root tied to that specific mutation.
Full detail on why the already-loaded, already-managed entity goes stale after a bulk mutation is in the HQL article’s bulk-operations section—identical caveat, different API surface.
When Criteria earns its ceremony over HQL
Neither API is strictly better—they solve different problems. For a fixed, known query, HQL reads closer to SQL and needs far less code. Criteria earns its verbosity specifically for dynamically-built predicates: a search endpoint with several optional filters is a genuinely painful HQL string-building exercise, and a clean loop of if (filter != null) predicates.add(...) in Criteria. Named queries (covered in a separate article) have no Criteria equivalent for startup validation—that benefit is exclusive to static HQL text.
Further reading
- hibernate-demo companion repository and its full Criteria API chapter
- Hibernate Query Language (HQL) in Hibernate 7 — the static-query counterpart to this article
- Hibernate 7 named queries — startup validation with no Criteria equivalent
- Chapter 11, proxies and lazy initialization — what an untouched association actually is before it’s loaded
- Jakarta Persistence 3.2 specification, Criteria API
No Comments yet!