A Hibernate criteria query is a database query that we build from Java objects instead of writing it as a query string. The Jakarta Persistence Criteria API gives us a CriteriaBuilder to create conditions, a CriteriaQuery that holds the select, where and order by parts, and a Root that represents the entity we query. Hibernate converts the criteria objects into SQL when the query runs.
Because the query is plain Java code, the Criteria API is the right tool when the conditions are decided at runtime, for example by the fields a user fills in a search form on a real estate site.
The following example maps a real estate listing and selects the active listings under 300000, cheapest first.
@Entity
public class Listing {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String title;
private BigDecimal price;
private int bedrooms;
private LocalDate listedOn;
@Enumerated(EnumType.STRING)
private ListingStatus status; // ACTIVE, PENDING, SOLD
@ManyToOne(fetch = FetchType.LAZY)
private Neighborhood neighborhood;
}
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Listing> query = cb.createQuery(Listing.class);
Root<Listing> listing = query.from(Listing.class);
query.select(listing)
.where(cb.equal(listing.get("status"), ListingStatus.ACTIVE),
cb.lessThan(listing.get("price"), new BigDecimal("300000")))
.orderBy(cb.asc(listing.get("price")));
List<Listing> result = em.createQuery(query).getResultList();
// result = [Studio Nest, Sunny Loft, Garden Flat]
// select l1_0.id,...,l1_0.title from Listing l1_0 where l1_0.status=? and l1_0.price<? order by l1_0.price
Notice that each condition is a CriteriaBuilder method call, and the value 300000 reaches the SQL as a ? parameter.
Next, we look at the objects of a criteria query and compare them with HQL. After that, we filter, sort, join, group, page, update and delete with CriteriaBuilder in Hibernate 7, and we check the SQL that Hibernate generates for each query.
1. How a Criteria Query Works
A criteria query is made of a few objects, and each object holds one part of the query that becomes one SQL clause.
| Interface | What it is | We get it from |
|---|---|---|
| CriteriaBuilder | Factory for queries, conditions, functions and sort orders | em.getCriteriaBuilder() |
| CriteriaQuery<T> | The query itself; T is the type of each result row | cb.createQuery(Listing.class) |
| Root<X> | The entity in the from clause, the start of every path | query.from(Listing.class) |
| Path | An attribute of an entity, for example the price | listing.get(“price”) |
| Predicate | A condition that is true or false | cb.equal(), cb.like(), cb.and() |
| Join / Fetch | An association joined in SQL | listing.join(“neighborhood”) |
The diagram maps the intro snippet to the SQL that Hibernate printed for it, one method call per clause.

We can also write the same query as a string in HQL or JPQL. Both run through the same query engine in Hibernate 6 and 7, so the generated SQL is the same, and the choice depends on how we build the query.
| Criteria API | HQL / JPQL string | |
|---|---|---|
| Written as | Java method calls | A string such as “from Listing where price < :max” |
| Conditions known at | Runtime (added with if statements) | Coding time |
| Typo in an attribute name | Runtime error, or compile error with the static metamodel | Runtime error |
| Reading a long query | Harder, many lines of code | Easier, close to SQL |
| Typical use | Search screens, optional filters, generic repositories | Fixed queries |
The old Hibernate API session.createCriteria() with Restrictions and Projections was deprecated in Hibernate 5.2 and removed in Hibernate 6. Section 3.1 maps each old call to the Jakarta Persistence Criteria API used in this guide.
2. Criteria Query Examples
The examples run on Hibernate 7.4, Java 25 and an in-memory H2 database. The complete project on GitHub runs every query in this section, prints its SQL and checks each result with 27 JUnit tests (mvn -q compile exec:java, mvn test).
2.1. The Real Estate Listing Model
Our example is a real estate site that shows listings. Each listing belongs to a neighborhood in a city and has amenities such as a pool or a garden, and many listings share the same amenity.

Listing holds a @ManyToOne reference to its neighborhood and a @ManyToMany set of amenities. The neighborhood is loaded LAZY, which matters for fetch joins in section 2.8.
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "neighborhood_id")
private Neighborhood neighborhood;
@ManyToMany
@JoinTable(name = "listing_amenity",
joinColumns = @JoinColumn(name = "listing_id"),
inverseJoinColumns = @JoinColumn(name = "amenity_id"))
private Set<Amenity> amenities = new HashSet<>();
@Entity
public class Neighborhood {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
private String city;
}
@Entity
public class Amenity {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
}
2.2. Selecting All Listings
The smallest criteria query creates the query for a result type, adds a root and selects the root. The call em.createQuery(query) turns the criteria query into a TypedQuery that we run like any other query.
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Listing> query = cb.createQuery(Listing.class);
Root<Listing> listing = query.from(Listing.class);
query.select(listing);
List<Listing> all = em.createQuery(query).getResultList();
// all = [Sunny Loft, River Cottage, Family Home, Corner Condo, Lake House, Garden Flat, Studio Nest, Hilltop Villa]
// select l1_0.id,l1_0.bedrooms,l1_0.listedOn,l1_0.neighborhood_id,l1_0.price,l1_0.status,l1_0.title from Listing l1_0
We run the query inside a transaction or an open EntityManager, so in the project each query runs in emf.runInTransaction(em -> …) on an EntityManagerFactory.
2.3. Filtering With where
A condition is a Predicate that we create with a CriteriaBuilder method and pass to where(). The call listing.get(“price”) returns the Path to an attribute, and the CriteriaBuilder method compares the path with a value. Each row of the table pairs a condition with its code and the SQL that Hibernate printed.
| SQL condition | Criteria code | Generated SQL | Result |
|---|---|---|---|
| equal | cb.equal(listing.get(“status”), ACTIVE) | l1_0.status=? | 5 listings |
| not equal | cb.notEqual(listing.get(“status”), SOLD) | l1_0.status<>? | 7 listings |
| like | cb.like(listing.get(“title”), “%Lake%”) | l1_0.title like ? escape ” | Lake House |
| between | cb.between(listing.get(“price”), low, high) | l1_0.price between ? and ? | 3 listings |
| in | listing.get(“status”).in(PENDING, SOLD) | l1_0.status in (?,?) | 3 listings |
| is null | cb.isNull(listing.get(“neighborhood”)) | l1_0.neighborhood_id is null | Studio Nest |
| >= on a number | cb.ge(listing.get(“bedrooms”), 3) | l1_0.bedrooms>=? | 3 listings |
| > on a date | cb.greaterThan(listing.get(“listedOn”), date) | l1_0.listedOn>? | 3 listings |
The CriteriaBuilder interface has two families of comparison methods.
- The methods gt(), ge(), lt() and le() accept only Number values, such as the bedrooms or the price.
- The methods greaterThan(), greaterThanOrEqualTo(), lessThan() and lessThanOrEqualTo() accept any Comparable, so we use them for a LocalDate, a String or a BigDecimal.
Hibernate sends every value as a JDBC parameter (the ? marks), never as text inside the SQL. When we want to run one query object with different values, we declare the parameter ourselves with cb.parameter().
ParameterExpression<BigDecimal> maxPrice = cb.parameter(BigDecimal.class, "maxPrice");
query.select(listing)
.where(cb.le(listing.get("price"), maxPrice))
.orderBy(cb.asc(listing.get("price")));
TypedQuery<Listing> typed = em.createQuery(query);
List<Listing> under200k = typed.setParameter("maxPrice", new BigDecimal("200000")).getResultList(); // [Studio Nest]
List<Listing> under300k = typed.setParameter("maxPrice", new BigDecimal("300000")).getResultList(); // [Studio Nest, Corner Condo, Sunny Loft, Garden Flat]
// select l1_0.id,...,l1_0.title from Listing l1_0 where l1_0.price<=? order by l1_0.price
2.4. Combining Conditions With and, or and not
When we pass several predicates to where(), Hibernate joins them with and. For an or, or for a group in parentheses, we nest cb.and() and cb.or().
query.select(listing)
.where(cb.and(
cb.equal(listing.get("status"), ListingStatus.ACTIVE),
cb.or(
cb.ge(listing.get("bedrooms"), 2),
cb.lt(listing.get("price"), 200000))));
// [River Cottage, Lake House, Garden Flat, Studio Nest]
// ... where l1_0.status=? and (l1_0.bedrooms>=? or l1_0.price<?)
The method cb.not() negates a predicate, and Hibernate simplifies not (status = ?) to status <> ?.
query.where(cb.not(cb.equal(listing.get("status"), ListingStatus.ACTIVE)));
// [Family Home, Corner Condo, Hilltop Villa]
// ... where l1_0.status<>?
2.5. Sorting the Results
The method orderBy() takes one or more Order objects from cb.asc() and cb.desc(). The first order sorts the rows, and the next ones sort rows with equal values, as in SQL sorting.
query.select(listing)
.orderBy(cb.desc(listing.get("bedrooms")), cb.asc(listing.get("price")));
// [Hilltop Villa, Family Home, Lake House, Garden Flat, River Cottage, Studio Nest, Corner Condo, Sunny Loft]
// ... order by l1_0.bedrooms desc,l1_0.price
2.6. Selecting Attributes Instead of Entities
A query does not have to return entities. Selecting only the columns we show on a screen reads less data, and Hibernate does not track the results as managed entities. The result type of createQuery() decides what each row is.
| Result type | Create the query with | Select with | Read a value |
|---|---|---|---|
| One attribute | cb.createQuery(String.class) | select(listing.get(“title”)) | The element itself |
| Tuple | cb.createTupleQuery() | multiselect(path.alias(“title”), …) | row.get(“title”, String.class) |
| Object[] | cb.createQuery(Object[].class) | multiselect(path1, path2) | row[0], row[1] |
| Record or DTO | cb.createQuery(ListingSummary.class) | select(cb.construct(ListingSummary.class, …)) | summary.title() |
A Tuple is a row whose values we read by alias or by position.
CriteriaQuery<Tuple> query = cb.createTupleQuery();
Root<Listing> listing = query.from(Listing.class);
query.multiselect(listing.get("title").alias("title"), listing.get("price").alias("price"))
.where(cb.equal(listing.get("status"), ListingStatus.PENDING))
.orderBy(cb.asc(listing.get("price")));
for (Tuple row : em.createQuery(query).getResultList()) {
String title = row.get("title", String.class); // Family Home, Hilltop Villa
BigDecimal price = row.get("price", BigDecimal.class); // 480000.00, 720000.00
}
// select l1_0.title,l1_0.price from Listing l1_0 where l1_0.status=? order by 2
For results that leave the data layer, we prefer a record filled by cb.construct(). The arguments must match the record’s constructor in order and type.
public record ListingSummary(String title, BigDecimal price) { }
CriteriaQuery<ListingSummary> query = cb.createQuery(ListingSummary.class);
Root<Listing> listing = query.from(Listing.class);
query.select(cb.construct(ListingSummary.class, listing.get("title"), listing.get("price")))
.where(cb.equal(listing.get("status"), ListingStatus.PENDING))
.orderBy(cb.asc(listing.get("price")));
// [ListingSummary[title=Family Home, price=480000.00], ListingSummary[title=Hilltop Villa, price=720000.00]]
// select l1_0.title,l1_0.price from Listing l1_0 where l1_0.status=? order by 2
2.7. Joining Associations
The call root.join(“attribute”) joins an association and returns a Join, which we use like a root to reach the other entity’s attributes. The default is an inner join, so rows without a match are dropped.
Join<Listing, Neighborhood> neighborhood = listing.join("neighborhood");
query.select(listing)
.where(cb.equal(neighborhood.get("city"), "Austin"));
// [Sunny Loft, River Cottage, Family Home, Corner Condo]
// ... from Listing l1_0 join Neighborhood n1_0 on n1_0.id=l1_0.neighborhood_id where n1_0.city=?
JoinType.LEFT keeps the listings without a neighborhood and returns null for the joined columns.
CriteriaQuery<Tuple> query = cb.createTupleQuery();
Root<Listing> listing = query.from(Listing.class);
Join<Listing, Neighborhood> neighborhood = listing.join("neighborhood", JoinType.LEFT);
query.multiselect(listing.get("title"), neighborhood.get("name"))
.where(cb.greaterThanOrEqualTo(listing.get("listedOn"), LocalDate.of(2026, 9, 12)));
// Garden Flat -> Lakeview, Studio Nest -> null, Hilltop Villa -> Lakeview
// select l1_0.title,n1_0.name from Listing l1_0 left join Neighborhood n1_0 on n1_0.id=l1_0.neighborhood_id where l1_0.listedOn>=? ...
A collection association is joined the same way. For the many-to-many amenities, Hibernate joins the listing_amenity table and after that the Amenity table.
Join<Listing, Amenity> amenity = listing.join("amenities");
query.select(listing)
.where(cb.equal(amenity.get("name"), "Pool"));
// [Lake House, Hilltop Villa]
// ... from Listing l1_0 join listing_amenity a1_0 on l1_0.id=a1_0.listing_id
// join Amenity a1_1 on a1_1.id=a1_0.amenity_id where a1_1.name=?
A join on a collection can return one row per match, and because “Family Home” has both a garage and a garden, a query for in (“Garage”, “Garden”) returns its SQL row twice. Section 3.3 shows when we need distinct().
2.8. Fetch Joins and the N+1 Problem
A join() is only for filtering and selecting, so it does not load the association into the entity. Because neighborhood is LAZY, Hibernate puts a proxy in the field and runs one more select the first time we read each different neighborhood. The extra selects are the N+1 problem, one query for the list plus one query per related row.
query.select(listing).where(cb.isNotNull(listing.get("neighborhood")));
for (Listing l : em.createQuery(query).getResultList()) {
String name = l.getNeighborhood().getName(); // loads each neighborhood on first use
}
// select l1_0.id,...,l1_0.title from Listing l1_0 where l1_0.neighborhood_id is not null order by l1_0.id
// select n1_0.id,n1_0.city,n1_0.name from Neighborhood n1_0 where n1_0.id=? (Riverside)
// select n1_0.id,n1_0.city,n1_0.name from Neighborhood n1_0 where n1_0.id=? (Old Town)
// select n1_0.id,n1_0.city,n1_0.name from Neighborhood n1_0 where n1_0.id=? (Lakeview)
The call root.fetch(“attribute”) is the Criteria version of join fetch. It adds the joined columns to the select and fills the association in the same statement.
listing.fetch("neighborhood");
query.select(listing);
// select l1_0.id,l1_0.bedrooms,l1_0.listedOn,n1_0.id,n1_0.city,n1_0.name,l1_0.price,l1_0.status,l1_0.title
// from Listing l1_0 join Neighborhood n1_0 on n1_0.id=l1_0.neighborhood_id order by l1_0.id
A fetch join is an inner join by default, so it drops rows without a match. Studio Nest is missing from the fetch join result. To keep Studio Nest, or to fetch a collection that can be empty, we pass JoinType.LEFT.
listing.fetch("amenities", JoinType.LEFT);
query.select(listing).where(cb.equal(listing.get("status"), ListingStatus.PENDING));
// Family Home [Garage, Garden], Hilltop Villa [Garden, Pool] (1 statement)
// select l1_0.id,a1_0.listing_id,a1_1.id,a1_1.name,... from Listing l1_0
// left join listing_amenity a1_0 on l1_0.id=a1_0.listing_id left join Amenity a1_1 on a1_1.id=a1_0.amenity_id where l1_0.status=?
2.9. Grouping With group by and having
The methods groupBy() and having() work with the aggregate functions of CriteriaBuilder, such as count(), sum(), avg(), min() and max(). For example, a market report lists the neighborhoods whose average listing price is above 300000, with one record per group.
public record NeighborhoodStats(String neighborhood, Long listings, Double averagePrice) { }
CriteriaQuery<NeighborhoodStats> query = cb.createQuery(NeighborhoodStats.class);
Root<Listing> listing = query.from(Listing.class);
Join<Listing, Neighborhood> neighborhood = listing.join("neighborhood");
query.select(cb.construct(NeighborhoodStats.class,
neighborhood.get("name"),
cb.count(listing),
cb.avg(listing.get("price"))))
.groupBy(neighborhood.get("name"))
.having(cb.gt(cb.avg(listing.get("price")), 300000))
.orderBy(cb.asc(neighborhood.get("name")));
// [NeighborhoodStats[neighborhood=Lakeview, listings=3, averagePrice=555000.0],
// NeighborhoodStats[neighborhood=Old Town, listings=2, averagePrice=345000.0]]
// select n1_0.name c0,count(l1_0.id) c1,avg(l1_0.price) c2 from Listing l1_0 join Neighborhood n1_0
// on n1_0.id=l1_0.neighborhood_id group by c0 having avg(l1_0.price)>? order by 1
The call cb.count(listing) counts entities and becomes count(l1_0.id). The method cb.avg() always returns a Double, so the record field is a Double even though the price is a BigDecimal. The having clause removes Riverside, whose average price is 285000.
2.10. Subqueries
The call query.subquery(Type.class) creates a Subquery with its own root, which we can use anywhere an expression is allowed. The following subquery compares each price with the average of all listings.
Subquery<Double> average = query.subquery(Double.class);
Root<Listing> other = average.from(Listing.class);
average.select(cb.avg(other.get("price")));
query.select(listing)
.where(cb.gt(listing.get("price"), average))
.orderBy(cb.asc(listing.get("price")));
// [Family Home, Lake House, Hilltop Villa]
// ... where l1_0.price>(select avg(l2_0.price) from Listing l2_0) order by l1_0.price
A correlated subquery refers to the outer root. With cb.exists() and cb.not() it finds neighborhoods that have no active listing, including Hillcrest, which has no listing at all.
CriteriaQuery<Neighborhood> query = cb.createQuery(Neighborhood.class);
Root<Neighborhood> neighborhood = query.from(Neighborhood.class);
Subquery<Integer> active = query.subquery(Integer.class);
Root<Listing> listing = active.from(Listing.class);
active.select(cb.literal(1))
.where(cb.equal(listing.get("neighborhood"), neighborhood),
cb.equal(listing.get("status"), ListingStatus.ACTIVE));
query.select(neighborhood)
.where(cb.not(cb.exists(active)))
.orderBy(cb.asc(neighborhood.get("name")));
// [Hillcrest, Old Town]
// ... where not exists(select 1 from Listing l1_0 where l1_0.neighborhood_id=n1_0.id and l1_0.status=?)
2.11. Pagination With a Count Query
Pagination is set on the TypedQuery, not on the criteria query. The method setFirstResult() sets the zero-based offset and setMaxResults() sets the page size. A stable page order needs a unique sort key, so we add the id after the price.
query.select(listing)
.where(cb.equal(listing.get("status"), ListingStatus.ACTIVE))
.orderBy(cb.asc(listing.get("price")), cb.asc(listing.get("id")));
List<Listing> content = em.createQuery(query)
.setFirstResult((2 - 1) * 2) // skip page 1
.setMaxResults(2)
.getResultList(); // [Garden Flat, River Cottage]
// ... where l1_0.status=? order by l1_0.price,l1_0.id offset ? rows fetch first ? rows only
To show “page 2 of 3”, a second query counts all rows that match the same condition. The count query needs its own root, because a root belongs to one query.
CriteriaQuery<Long> count = cb.createQuery(Long.class);
Root<Listing> counted = count.from(Listing.class);
count.select(cb.count(counted))
.where(cb.equal(counted.get("status"), ListingStatus.ACTIVE));
long total = em.createQuery(count).getSingleResult(); // 5, so 3 pages
// select count(l1_0.id) from Listing l1_0 where l1_0.status=?
Section 2.15 shows a Hibernate method that creates the count query from the first one.
2.12. Building Dynamic Filters With a List of Predicates
Dynamic filters are the main use case for criteria queries, because a search form has optional fields and only the filled fields should become conditions. We collect the predicates in a List<Predicate> and pass the list to where().
public record ListingFilter(String city, BigDecimal maxPrice, Integer minBedrooms,
ListingStatus status, String keyword) { }
List<Predicate> predicates = new ArrayList<>();
if (filter.city() != null) {
Join<Listing, Neighborhood> neighborhood = listing.join("neighborhood");
predicates.add(cb.equal(neighborhood.get("city"), filter.city()));
}
if (filter.maxPrice() != null) {
predicates.add(cb.le(listing.get("price"), filter.maxPrice()));
}
if (filter.minBedrooms() != null) {
predicates.add(cb.ge(listing.get("bedrooms"), filter.minBedrooms()));
}
if (filter.status() != null) {
predicates.add(cb.equal(listing.get("status"), filter.status()));
}
if (filter.keyword() != null && !filter.keyword().isBlank()) {
predicates.add(cb.like(cb.lower(listing.get("title")), "%" + filter.keyword().toLowerCase() + "%"));
}
query.select(listing)
.where(predicates) // Jakarta Persistence 3.2: where(List<Predicate>)
.orderBy(cb.asc(listing.get("price")));
An empty list adds no where clause at all, and we add the join only when the city filter needs it, so the SQL stays small.

| Filter | Result |
|---|---|
| All fields null | All 8 listings, … from Listing l1_0 order by l1_0.price |
| city = “Denver”, minBedrooms = 2 | [Garden Flat, Lake House, Hilltop Villa] |
| maxPrice = 300000, ACTIVE, keyword = “garden” | [Garden Flat], where l1_0.price<=? and l1_0.status=? and lower(l1_0.title) like ? escape ” |
Before Jakarta Persistence 3.2, where() accepted only an array, so older code calls where(predicates.toArray(new Predicate[0])). Since 3.2, the methods cb.and(predicates) and cb.or(predicates) also accept a list, so we can join a group of optional fields with or.
2.13. Bulk Updates and Deletes With CriteriaUpdate and CriteriaDelete
CriteriaUpdate and CriteriaDelete build one SQL update or delete statement that changes many rows at once. The method executeUpdate() either returns the number of changed rows or throws a TransactionRequiredException when no transaction is active.
CriteriaUpdate<Listing> update = cb.createCriteriaUpdate(Listing.class);
Root<Listing> listing = update.from(Listing.class);
update.set(listing.get("status"), ListingStatus.SOLD)
.where(cb.equal(listing.get("status"), ListingStatus.PENDING),
cb.lessThan(listing.get("listedOn"), LocalDate.of(2026, 9, 10)));
int updated = em.createQuery(update).executeUpdate(); // 1 (Family Home)
// update Listing l1_0 set status=? where l1_0.status=? and l1_0.listedOn<?
The method set() also accepts an expression that the database computes. In the following update, every active listing that has been on the market for 21 days or more gets 10000 cheaper.
Path<BigDecimal> price = listing.get("price");
update.set(price, cb.diff(price, new BigDecimal("10000")))
.where(cb.equal(listing.get("status"), ListingStatus.ACTIVE),
cb.lessThanOrEqualTo(listing.get("listedOn"), today.minusDays(21)));
// 4 rows: Sunny Loft 240000.00, River Cottage 310000.00, Lake House 640000.00, Garden Flat 285000.00
// update Listing l1_0 set price=(l1_0.price-cast(? as numeric(38,2))) where l1_0.status=? and l1_0.listedOn<=?
A CriteriaDelete removes the rows of an entity. For Listing, Hibernate first deletes the matching rows of the listing_amenity join table, so the statement does not violate the foreign key constraint.
CriteriaDelete<Listing> delete = cb.createCriteriaDelete(Listing.class);
Root<Listing> listing = delete.from(Listing.class);
delete.where(cb.equal(listing.get("status"), ListingStatus.SOLD));
int deleted = em.createQuery(delete).executeUpdate(); // 1 (Corner Condo)
// delete from listing_amenity to_delete_ where to_delete_.listing_id in (select l1_0.id from Listing l1_0 where l1_0.status=?)
// delete from Listing l1_0 where l1_0.status=?
Bulk statements run in the database without loading any entity, and two rules follow from that.
- They skip the persistence context. A Listing loaded before the update still shows PENDING in memory; em.refresh() reads the new SOLD value.
- Rows of other tables that still reference the deleted rows make the statement fail. Deleting the Riverside neighborhood while two listings point to it throws a ConstraintViolationException.
org.hibernate.exception.ConstraintViolationException: JDBC exception executing SQL [Referential integrity constraint violation: "FK2X523VXILUTALWC1UUO0SMUR4: PUBLIC.LISTING FOREIGN KEY(NEIGHBORHOOD_ID) REFERENCES PUBLIC.NEIGHBORHOOD(ID) (CAST(1 AS BIGINT))"; SQL statement:
delete from Neighborhood n1_0 where n1_0.name=? [23503-252]] [delete from Neighborhood n1_0 where n1_0.name=?]
To delete a few entities with their associations, deleting the entities one by one with em.remove() is the safer choice.
2.14. Type-Safe Queries With the Static Metamodel
The call listing.get(“prise”) compiles, because the attribute name is a String. The typo shows up only when the query is built at runtime.
org.hibernate.query.sqm.PathElementException: Could not resolve attribute 'prise' of 'com.howtodoinjava.hibernate.criteria.Listing'
The static metamodel prevents such typos. An annotation processor generates a class Listing_ with one constant per attribute, and the compiler checks every use. In Hibernate 7 the processor is the hibernate-processor artifact, and we add it to the compiler plugin.
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<version>3.16.0</version>
<configuration>
<annotationProcessorPaths>
<path>
<groupId>org.hibernate.orm</groupId>
<artifactId>hibernate-processor</artifactId>
<version>7.4.11.Final</version>
</path>
</annotationProcessorPaths>
</configuration>
</plugin>
The command mvn compile writes Listing_, Neighborhood_ and Amenity_ to target/generated-sources/annotations. Each attribute has a typed field and a String constant.
@StaticMetamodel(Listing.class)
public abstract class Listing_ {
public static final String PRICE = "price";
public static volatile SingularAttribute<Listing, BigDecimal> price;
public static volatile SingularAttribute<Listing, Neighborhood> neighborhood;
public static volatile SetAttribute<Listing, Amenity> amenities;
// ... one pair per attribute
}
The query uses the generated fields instead of strings. The call listing.get(Listing_.price) returns a Path<BigDecimal>, so comparing the price with a String does not compile either.
Join<Listing, Neighborhood> neighborhood = listing.join(Listing_.neighborhood);
query.select(listing)
.where(cb.equal(neighborhood.get(Neighborhood_.city), "Austin"),
cb.le(listing.get(Listing_.price), new BigDecimal("400000")))
.orderBy(cb.asc(listing.get(Listing_.price)));
// [Corner Condo, Sunny Loft, River Cottage]
// ... join Neighborhood n1_0 on n1_0.id=l1_0.neighborhood_id where n1_0.city=? and l1_0.price<=? order by l1_0.price
Typo.java:4: error: cannot find symbol
return listing.get(Listing_.prise);
^
symbol: variable prise
location: class Listing_
2.15. HibernateCriteriaBuilder Extras
In Hibernate 7, em.getCriteriaBuilder() already returns a HibernateCriteriaBuilder, the Hibernate subtype of CriteriaBuilder. We get it with the correct type from the Session. Its extra methods do not work with other JPA providers, but they need less code.
HibernateCriteriaBuilder cb = em.unwrap(Session.class).getCriteriaBuilder();
// 1. Case-insensitive like
JpaCriteriaQuery<Listing> query = cb.createQuery(Listing.class);
JpaRoot<Listing> listing = query.from(Listing.class);
query.select(listing).where(cb.ilike(listing.get(Listing_.title), "%LAKE%"));
// [Lake House] ... where l1_0.title ilike ? escape ''
// 2. Count query derived from a select query (order by is dropped)
query.select(listing)
.where(cb.ge(listing.get(Listing_.bedrooms), 2))
.orderBy(cb.asc(listing.get(Listing_.price)));
long count = em.createQuery(query.createCountQuery()).getSingleResult(); // 5
// select count(*) from Listing l1_0 where l1_0.bedrooms>=?
// 3. Start from an HQL string and add conditions in code
JpaCriteriaQuery<Listing> fromHql = cb.createQuery("from Listing l where l.bedrooms >= 2", Listing.class);
JpaRoot<?> root = fromHql.getRoot(0, Listing.class);
fromHql.where(fromHql.getRestriction(), cb.equal(root.get("status"), ListingStatus.PENDING));
// [Family Home, Hilltop Villa] ... where l1_0.bedrooms>=2 and l1_0.status=?
The HibernateCriteriaBuilder JavaDoc lists every extension.
2.16. SQL Clause to Criteria Method Cheat Sheet
Every SQL clause has one Criteria method, so we can use the table as a quick lookup for the methods in this guide.
| SQL | Criteria API |
|---|---|
| select l | query.select(root) |
| select l.title, l.price | query.multiselect(…) or cb.construct(Dto.class, …) |
| select distinct | query.distinct(true) |
| from Listing l | query.from(Listing.class) |
| join / left join | root.join(“attr”), root.join(“attr”, JoinType.LEFT) |
| join fetch | root.fetch(“attr”) |
| where a = ? and b > ? | query.where(cb.equal(…), cb.gt(…)) |
| or, not | cb.or(…), cb.not(…) |
| like, between, in, is null | cb.like(), cb.between(), path.in(), cb.isNull() |
| count, sum, avg, min, max | cb.count(), cb.sum(), cb.avg(), cb.min(), cb.max() |
| group by / having | query.groupBy(…), query.having(…) |
| order by price desc | query.orderBy(cb.desc(path)) |
| offset / fetch first | typedQuery.setFirstResult(), setMaxResults() |
| Subquery, exists | query.subquery(Type.class), cb.exists(subquery) |
| update … set | cb.createCriteriaUpdate(), update.set(path, value) |
| delete from | cb.createCriteriaDelete() |
| Named parameter :max | cb.parameter(Type.class, “max”) |
3. Criteria Query FAQs
3.1. What Replaced session.createCriteria() in Hibernate 6?
The legacy org.hibernate.Criteria, Restrictions, Projections and session.createCriteria() were deprecated in Hibernate 5.2 and removed in 6.0. In Hibernate 7.4, org.hibernate.Criteria does not exist and Session has no createCriteria() method. Each old call has a Jakarta Persistence replacement.
| Legacy Hibernate Criteria | Jakarta Persistence Criteria API |
|---|---|
| session.createCriteria(Listing.class) | cb.createQuery(Listing.class) + query.from(Listing.class) |
| crit.add(Restrictions.eq(“status”, ACTIVE)) | query.where(cb.equal(root.get(“status”), ACTIVE)) |
| Restrictions.like / gt / between / isNull / in | cb.like(), cb.gt(), cb.between(), cb.isNull(), path.in() |
| Restrictions.ilike() | cb.like(cb.lower(path), …) or HibernateCriteriaBuilder.ilike() |
| Restrictions.or(), Restrictions.disjunction() | cb.or(…), cb.or(List<Predicate>) |
| crit.addOrder(Order.desc(“price”)) | query.orderBy(cb.desc(root.get(“price”))) |
| crit.createCriteria(“neighborhood”) | root.join(“neighborhood”) |
| Projections.rowCount() | cb.count(root) |
| Projections.projectionList() | query.multiselect(…) or cb.construct(…) |
| crit.setFirstResult() / setMaxResults() | Same methods on em.createQuery(query) |
| crit.uniqueResult() | getSingleResult() or getSingleResultOrNull() |
| crit.list() | em.createQuery(query).getResultList() |
| Restrictions.sqlRestriction() | No direct replacement; use a native SQL query |
| Example.create(obj) (query by example) | No replacement in Hibernate 7; build predicates from the non-null fields as in section 2.12 |
3.2. Is the Criteria API Safe From SQL Injection?
Yes, for values. Every value passed to a CriteriaBuilder method is bound as a JDBC parameter. A keyword such as x’ or ‘1’=’1 from a search field becomes the value of like ? and matches no listing.
ListingFilter filter = new ListingFilter(null, null, null, null, "x' or '1'='1");
// 0 listings
// ... where lower(l1_0.title) like ? escape '' order by l1_0.price
The protection does not cover attribute names. Passing a sort column from a request into listing.get(request.sort()) without a check lets a user sort or filter by any attribute of the entity, so we check such names against an allow list or use the metamodel.
3.3. Do I Need distinct() When Joining a Collection?
It depends on the result type. Since Hibernate 6, an entity result never contains the same entity twice, so distinct() is not needed. A scalar result keeps the duplicate rows.
| Query for Garage or Garden | Without distinct() | With distinct(true) |
|---|---|---|
| select(listing) | 6 listings, Family Home once | 6 listings, select distinct sent to the database |
| select(listing.get(“title”)) | 7 titles, Family Home twice | 6 titles |
3.4. Why Is the Listing_ Metamodel Class Not Generated?
The metamodel classes appear only when the annotation processor runs, so a missing Listing_ class has one of these causes.
- The project uses the old artifact name hibernate-jpamodelgen. Hibernate 7 publishes the processor as hibernate-processor; on Maven Central, hibernate-jpamodelgen 7.4.11.Final is only a relocation pom that points to it.
- The processor is a normal dependency, but the build lists annotationProcessorPaths for another processor such as Lombok. Maven then looks for processors only in that list, so hibernate-processor must be added there too.
- The IDE does not treat target/generated-sources/annotations as a source folder, so it shows Listing_ as missing although Maven compiles.
3.5. How Do I See the SQL of a Criteria Query?
We turn on SQL logging when we build the EntityManagerFactory. With HibernatePersistenceConfiguration, the call showSql(true, false, false) prints each statement.
HibernatePersistenceConfiguration config = new HibernatePersistenceConfiguration("criteria")
.showSql(true, false, false); // Hibernate: select l1_0.id,... from Listing l1_0 ...
In persistence.xml, the same setting is the hibernate.show_sql property. To see the values of the ? parameters, we set the org.hibernate.orm.jdbc.bind logger to the TRACE level.
4. Conclusion
A criteria query builds SQL from Java objects. The CriteriaBuilder creates the conditions, the CriteriaQuery holds the clauses, and Root, Join and Fetch point to the entities.
We use a criteria query when the conditions depend on runtime input. We collect optional conditions in a List<Predicate>, add fetch() where the code reads an association, and generate the static metamodel so that the compiler checks attribute names. For fixed queries, an HQL string is shorter and easier to read, and both produce the same SQL in Hibernate 7.
5. References
- Jakarta Persistence 3.2 specification: Criteria API
- CriteriaBuilder JavaDoc (Jakarta Persistence 3.2)
- CriteriaQuery JavaDoc (Jakarta Persistence 3.2)
- Hibernate ORM 7.4 User Guide: Criteria
- Hibernate ORM 7.4 User Guide: Hibernate Metamodel Generator
- HibernateCriteriaBuilder JavaDoc (Hibernate 7.4)
- Hibernate ORM 6.0 Migration Guide: Legacy Hibernate Criteria API
Happy Learning !!