JPQL, Criteria API & Queries

The Criteria API

18 min Lesson 5 of 13

The Criteria API

JPQL is expressive and readable, but it has a fundamental weakness: your query is a plain string. A typo in an entity name, a misspelled field, or a malformed join clause compiles cleanly and only blows up at runtime. The Criteria API solves this by letting you build queries programmatically — as a tree of Java objects — so the compiler and your IDE can validate every part of it before the app ever runs.

The Criteria API lives in the jakarta.persistence.criteria package and is tightly integrated with the JPA EntityManager. In Spring Boot 3 with Hibernate 6 you already have everything you need on the classpath — there is nothing extra to add.

The Three Core Objects

Every Criteria query is assembled from three collaborating objects:

  • CriteriaBuilder — the factory. You get one from the EntityManager. It creates query objects, expressions, predicates, and ordering clauses. Think of it as the JPQL keyword set made into a Java API.
  • CriteriaQuery<T> — the query definition. It carries the result type, the FROM clause, the WHERE predicate, ordering, and grouping. One CriteriaQuery per logical query.
  • Root<T> — the entity range variable, equivalent to the alias in FROM Order o. It gives you typed access to the entity's persistent attributes via get("fieldName") or, with the Metamodel, get(Order_.status).
CriteriaBuilder is cheap to call but Root must match CriteriaQuery. Always obtain the Root from the same CriteriaQuery it will be used in — mixing roots across query objects is a common mistake that produces a runtime exception.

Building a Simple SELECT Query

The pattern is always: obtain CriteriaBuilder → create CriteriaQuery → add Root → configure predicates → execute with TypedQuery.

import jakarta.persistence.EntityManager; import jakarta.persistence.TypedQuery; import jakarta.persistence.criteria.CriteriaBuilder; import jakarta.persistence.criteria.CriteriaQuery; import jakarta.persistence.criteria.Root; import org.springframework.stereotype.Repository; import java.util.List; @Repository public class OrderRepository { private final EntityManager em; public OrderRepository(EntityManager em) { this.em = em; } public List<Order> findByStatus(String status) { CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery<Order> cq = cb.createQuery(Order.class); Root<Order> order = cq.from(Order.class); cq.select(order) .where(cb.equal(order.get("status"), status)); TypedQuery<Order> query = em.createQuery(cq); return query.getResultList(); } }

Notice the result of em.createQuery(cq) is a typed TypedQuery<Order>. Unlike the string-based createQuery(String), the compiler knows the return type; no unchecked cast needed.

Adding Ordering and Pagination

Ordering and pagination attach to either the CriteriaQuery or the TypedQuery:

public List<Order> findRecentOrders(int page, int pageSize) { CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery<Order> cq = cb.createQuery(Order.class); Root<Order> order = cq.from(Order.class); cq.select(order) .orderBy(cb.desc(order.get("createdAt"))); // ORDER BY createdAt DESC TypedQuery<Order> query = em.createQuery(cq); query.setFirstResult(page * pageSize); // OFFSET query.setMaxResults(pageSize); // LIMIT return query.getResultList(); }
Prefer setMaxResults over in-memory truncation. The JPA provider translates setMaxResults / setFirstResult into the database-native pagination syntax (LIMIT/OFFSET on MySQL and PostgreSQL, FETCH FIRST on DB2, ROWNUM on older Oracle). The database discards rows before they travel across the network, which matters enormously at scale.

Combining Multiple Predicates

Real queries filter on several conditions. CriteriaBuilder.and() and cb.or() combine predicates exactly like SQL AND/OR. Each call returns a new Predicate — you compose them like building a boolean expression tree.

import jakarta.persistence.criteria.Predicate; import java.math.BigDecimal; import java.time.LocalDate; public List<Order> findFiltered(String status, BigDecimal minTotal, LocalDate since) { CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery<Order> cq = cb.createQuery(Order.class); Root<Order> order = cq.from(Order.class); Predicate statusPred = cb.equal(order.get("status"), status); Predicate totalPred = cb.greaterThanOrEqualTo(order.get("total"), minTotal); Predicate datePred = cb.greaterThanOrEqualTo(order.get("createdAt"), since); cq.select(order) .where(cb.and(statusPred, totalPred, datePred)); return em.createQuery(cq).getResultList(); }

cb.and(Predicate...) accepts a vararg so you can pass as many conditions as you need. The equivalent cb.or() is used for OR-groups. You can nest them to arbitrary depth.

Joining Related Entities

Joins in the Criteria API are explicit objects of type Join<Z, X>. You navigate from the Root using join(), specifying the relationship field name and optionally the join type.

import jakarta.persistence.criteria.Join; import jakarta.persistence.criteria.JoinType; public List<Order> findOrdersByCustomerCity(String city) { CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery<Order> cq = cb.createQuery(Order.class); Root<Order> order = cq.from(Order.class); // INNER JOIN order.customer c Join<Order, Customer> customer = order.join("customer", JoinType.INNER); cq.select(order) .where(cb.equal(customer.get("city"), city)) .distinct(true); return em.createQuery(cq).getResultList(); }

For a fetch join (which loads the association eagerly to avoid N+1) call order.fetch("items") instead of order.join("items"). A Fetch is not a Join, but it accepts the same JoinType parameter and produces the same SQL JOIN ... FETCH semantics.

Mixing fetch joins with setMaxResults causes an in-memory limit warning. When a fetch join produces a one-to-many result set and you also paginate, Hibernate has to fetch all rows into memory and paginate there — it cannot push the LIMIT to SQL safely. The fix is to paginate by entity ID in a first query, then fetch associations in a second query scoped to those IDs.

Scalar and Count Queries

Not every query returns full entities. To count rows, change the result type to Long and use cb.count():

public long countByStatus(String status) { CriteriaBuilder cb = em.getCriteriaBuilder(); CriteriaQuery<Long> cq = cb.createQuery(Long.class); Root<Order> order = cq.from(Order.class); cq.select(cb.count(order)) .where(cb.equal(order.get("status"), status)); return em.createQuery(cq).getSingleResult(); }

The same pattern works for cb.sum(), cb.max(), cb.avg() and other aggregate expressions — just change the generic type parameter on CriteriaQuery to match what the aggregate returns.

Why Choose the Criteria API Over JPQL?

  • Compile-time safety — typos in field names fail to compile, not at 2 AM in production.
  • Dynamic queries — you can conditionally add predicates in a loop, which is impossible with a string template without messy concatenation.
  • IDE support — auto-complete on CriteriaBuilder methods and Root.get() paths (even more with the Metamodel, covered next lesson).
  • Refactoring safety — if you rename an entity field, the compiler tells you every broken query location immediately.

The trade-off is verbosity. A five-line JPQL query becomes fifteen lines of Criteria code. For fixed, read-heavy queries JPQL is often clearer; for queries built dynamically from user input (search screens with optional filters) the Criteria API is the right tool.

Summary

The Criteria API builds JPA queries as composable Java objects rather than strings. The three core types — CriteriaBuilder, CriteriaQuery<T>, and Root<T> — map directly to the SELECT, FROM, and WHERE structure of SQL. You add predicates with cb.equal(), cb.greaterThan() and their variants, combine them with cb.and()/cb.or(), and execute via a typed TypedQuery. Joins are explicit Join objects; fetch joins use fetch(). The next lesson introduces the JPA Metamodel, which replaces string field names like "status" with generated type-safe constants, making Criteria queries even more robust.

ES
Edrees Salih
1 hour ago

We are still cooking the magic in the way!