HQL & the Criteria API

HQL query examples, the type-safe Criteria API, and when to reach for native SQL instead.

HQL: Hibernate Query Language

HQL (the JPA-standardized form is JPQL — the two are effectively the same language) queries entities and their fields, not tables and columns — you write SELECT u FROM User u, not SELECT * FROM users, and the query is portable across whichever database dialect Hibernate is configured for.

Java
Query<User> query = session.createQuery(
    "SELECT u FROM User u WHERE u.active = true AND u.createdAt > :since", User.class
);
query.setParameter("since", LocalDateTime.now().minusDays(30));
List<User> recentActiveUsers = query.getResultList();

Joining across a mapped relationship reads naturally, using the entity's own field names rather than foreign key columns:

Java
Query<Order> query = session.createQuery(
    "SELECT o FROM Order o JOIN o.customer c WHERE c.email = :email ORDER BY o.createdAt DESC",
    Order.class
);
query.setParameter("email", "ada@example.com");

Aggregate queries work as expected:

Java
Query<Long> countQuery = session.createQuery(
    "SELECT COUNT(o) FROM Order o WHERE o.status = :status", Long.class
);
countQuery.setParameter("status", OrderStatus.SHIPPED);
long shippedCount = countQuery.getSingleResult();

Always use named parameters (:since, :email) or positional parameters, never string-concatenate values directly into the query — string concatenation reopens exactly the SQL injection risk ORMs are meant to close off.

The Criteria API

HQL strings are only checked for correctness at runtime — a typo in a field name compiles fine and fails when the query actually executes. The Criteria API builds the same kind of query using type-safe Java method calls instead of a string, so a mismatched field name is a compile error rather than a runtime surprise:

Java
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<User> cq = cb.createQuery(User.class);
Root<User> user = cq.from(User.class);

cq.select(user).where(
    cb.and(
        cb.isTrue(user.get("active")),
        cb.greaterThan(user.get("createdAt"), LocalDateTime.now().minusDays(30))
    )
);

List<User> recentActiveUsers = entityManager.createQuery(cq).getResultList();

With the JPA metamodel (generated automatically by an annotation processor at build time), field references become fully type-checked instead of string-based .get("active") lookups:

Java
cq.select(user).where(
    cb.and(
        cb.isTrue(user.get(User_.active)),
        cb.greaterThan(user.get(User_.createdAt), LocalDateTime.now().minusDays(30))
    )
);

The Criteria API earns its keep for queries that are built up dynamically — for example, a search endpoint where each filter (status, date range, customer) is optional and the final query depends on which parameters the caller actually supplied. Building that up as a string (concatenating WHERE clauses conditionally) is fragile; composing Predicates conditionally in Criteria is not:

Java
List<Predicate> predicates = new ArrayList<>();
if (status != null) predicates.add(cb.equal(order.get("status"), status));
if (minTotal != null) predicates.add(cb.greaterThanOrEqualTo(order.get("total"), minTotal));

cq.select(order).where(predicates.toArray(new Predicate[0]));

When to reach for native SQL instead

Both HQL and Criteria still go through Hibernate's entity mapping. Some things genuinely need raw SQL:

  • Database-specific functions or extensions HQL has no equivalent for (window functions, full-text search operators, vendor-specific JSON operators).
  • A query already written, tuned, and reviewed by a DBA against the actual schema.
  • Bulk operations across huge tables where the overhead of entity mapping isn't worth paying for data you're not going to touch as objects anyway.
Java
Query nativeQuery = session.createNativeQuery(
    "SELECT * FROM orders WHERE total > :minTotal ORDER BY total DESC LIMIT 10", Order.class
);
nativeQuery.setParameter("minTotal", new BigDecimal("500"));
List<Order> topOrders = nativeQuery.getResultList();

Native SQL trades away database portability and some of Hibernate's automatic entity-graph awareness — the right tool when HQL genuinely can't express the query, not a default first choice.

Common mistakes

  • String-concatenating user input directly into an HQL query string instead of using named/positional parameters — reintroduces SQL-injection-style risk even though it's "just HQL."
  • Reaching for the Criteria API for every query out of a sense that it's "more correct" — for a fixed, simple query, plain HQL is shorter and just as safe; Criteria's real value is in dynamically composed queries.
  • Forgetting the metamodel (User_) exists and instead using raw string field names (user.get("active")) in Criteria queries, giving up the compile-time safety that's the entire point of using Criteria in the first place.
  • Reaching for native SQL by default "for performance" without first checking whether an equivalent HQL query, with proper fetch strategies, would have done the same job while staying portable.