Hibernate Criteria Query Guide: JPA CriteriaBuilder Examples

A Hibernate criteria query builds SQL from Java objects with CriteriaBuilder, CriteriaQuery and Root. Learn filters, joins, fetch joins, group by, subqueries, pagination, dynamic filters, bulk updates and the static metamodel in Hibernate 7, with the generated SQL for each query.

Jakarta_EE

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.

InterfaceWhat it isWe get it from
CriteriaBuilderFactory for queries, conditions, functions and sort ordersem.getCriteriaBuilder()
CriteriaQuery<T>The query itself; T is the type of each result rowcb.createQuery(Listing.class)
Root<X>The entity in the from clause, the start of every pathquery.from(Listing.class)
PathAn attribute of an entity, for example the pricelisting.get(“price”)
PredicateA condition that is true or falsecb.equal(), cb.like(), cb.and()
Join / FetchAn association joined in SQLlisting.join(“neighborhood”)

The diagram maps the intro snippet to the SQL that Hibernate printed for it, one method call per clause.

Diagram: on the left the Java lines CriteriaBuilder, CriteriaQuery, Root from Listing, select(listing), where with cb.equal on status and cb.lessThan on price, and orderBy cb.asc on price; arrows on the right point to the SQL parts from Listing l1_0, select l1_0.id to l1_0.title, where l1_0.status=?, and l1_0.price<?, order by l1_0.price
Every Criteria method call produces one SQL clause. Values such as ACTIVE are always sent as JDBC parameters.

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 APIHQL / JPQL string
Written asJava method callsA string such as “from Listing where price < :max”
Conditions known atRuntime (added with if statements)Coding time
Typo in an attribute nameRuntime error, or compile error with the static metamodelRuntime error
Reading a long queryHarder, many lines of codeEasier, close to SQL
Typical useSearch screens, optional filters, generic repositoriesFixed 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.

Entity diagram: Neighborhood with name and city has a one-to-many relation to Listing with title, price, bedrooms, listedOn, status, neighborhood and amenities; Listing has a many-to-many relation to Amenity through the listing_amenity table. Below it, a table of the 8 sample listings: Sunny Loft, River Cottage, Family Home, Corner Condo, Lake House, Garden Flat, Studio Nest and Hilltop Villa with their price, bedrooms, date, status, neighborhood and amenities
Eight listings in four neighborhoods. Studio Nest has no neighborhood and Hillcrest has no listing, so we can see how joins treat missing rows.

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 conditionCriteria codeGenerated SQLResult
equalcb.equal(listing.get(“status”), ACTIVE)l1_0.status=?5 listings
not equalcb.notEqual(listing.get(“status”), SOLD)l1_0.status<>?7 listings
likecb.like(listing.get(“title”), “%Lake%”)l1_0.title like ? escape ”Lake House
betweencb.between(listing.get(“price”), low, high)l1_0.price between ? and ?3 listings
inlisting.get(“status”).in(PENDING, SOLD)l1_0.status in (?,?)3 listings
is nullcb.isNull(listing.get(“neighborhood”))l1_0.neighborhood_id is nullStudio Nest
>= on a numbercb.ge(listing.get(“bedrooms”), 3)l1_0.bedrooms>=?3 listings
> on a datecb.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 typeCreate the query withSelect withRead a value
One attributecb.createQuery(String.class)select(listing.get(“title”))The element itself
Tuplecb.createTupleQuery()multiselect(path.alias(“title”), …)row.get(“title”, String.class)
Object[]cb.createQuery(Object[].class)multiselect(path1, path2)row[0], row[1]
Record or DTOcb.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.

Flow diagram: a ListingFilter with city Denver, maxPrice null, minBedrooms 2, status null and keyword null; the two filled fields become cb.equal on city and cb.ge on bedrooms, the null fields are skipped; the list of two predicates goes to where(predicates) and produces SQL with a join on Neighborhood and where n1_0.city=? and l1_0.bedrooms>=?, returning 3 listings. Below, an empty filter produces no where clause and 8 listings, and a filter with maxPrice 300000, status ACTIVE and keyword garden produces three conditions and 1 listing
The same method builds three different SQL statements. A null field adds nothing to the query.
FilterResult
All fields nullAll 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.

SQLCriteria API
select lquery.select(root)
select l.title, l.pricequery.multiselect(…) or cb.construct(Dto.class, …)
select distinctquery.distinct(true)
from Listing lquery.from(Listing.class)
join / left joinroot.join(“attr”), root.join(“attr”, JoinType.LEFT)
join fetchroot.fetch(“attr”)
where a = ? and b > ?query.where(cb.equal(…), cb.gt(…))
or, notcb.or(…), cb.not(…)
like, between, in, is nullcb.like(), cb.between(), path.in(), cb.isNull()
count, sum, avg, min, maxcb.count(), cb.sum(), cb.avg(), cb.min(), cb.max()
group by / havingquery.groupBy(…), query.having(…)
order by price descquery.orderBy(cb.desc(path))
offset / fetch firsttypedQuery.setFirstResult(), setMaxResults()
Subquery, existsquery.subquery(Type.class), cb.exists(subquery)
update … setcb.createCriteriaUpdate(), update.set(path, value)
delete fromcb.createCriteriaDelete()
Named parameter :maxcb.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 CriteriaJakarta 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 / incb.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 GardenWithout distinct()With distinct(true)
select(listing)6 listings, Family Home once6 listings, select distinct sent to the database
select(listing.get(“title”))7 titles, Family Home twice6 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

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.