Criteria API vs JPQL vs Native: Choosing the Right Lie
Criteria API vs JPQL vs Native: Choosing the Right Lie
The Lie
Pick one query approach and use it consistently. Criteria API is the “proper” way because it is type-safe. JPQL is fine for simple queries. Native SQL is a last resort.
The Reality
Each approach lies to you differently.
Criteria API promises type safety and composability. In practice, it produces code so verbose that the query intent disappears. A 3-line JPQL query becomes 15 lines of Criteria builder calls. The generated SQL is identical. The type safety catches column name typos at compile time, which IDE autocompletion already prevents in JPQL strings.
JPQL promises database independence with entity-oriented syntax. In practice, database independence matters to approximately zero production applications. You deploy to PostgreSQL or MySQL. You stay there. The abstraction JPQL provides is the abstraction you do not need, and the features it removes (window functions, CTEs, lateral joins) are the features you do need.
Native SQL promises full database power. In practice, native queries bypass Hibernate’s type mapping, require manual result mapping, and scatter database-specific syntax across your codebase. If you use native queries everywhere, you are using JDBC with extra steps.
No single approach is correct for all queries. The decision depends on the query.
The matrix lays out each approach’s strengths, weaknesses, and the query types it handles best. The critical column is SQL feature coverage: JPQL and Criteria API both top out at roughly 70% of SQL’s expressiveness, while native SQL covers the rest. Choosing between JPQL and Criteria API is a code style question. Choosing between JPQL/Criteria and native SQL is a capability question.
The Evidence
The same query in all three approaches. Find orders by status, date range, and optional customer name filter:
// JPQL: 6 lines, readable, intent is clear
@Query("""
SELECT o FROM Order o
WHERE o.status = :status
AND o.createdAt BETWEEN :start AND :end
AND (:customerName IS NULL
OR o.customer.name LIKE CONCAT('%', :customerName, '%'))
""")
List<Order> findOrders(@Param("status") OrderStatus status,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end,
@Param("customerName") String customerName);
// Criteria API: 25+ lines, same query
public List<Order> findOrders(OrderStatus status,
LocalDateTime start,
LocalDateTime end,
String customerName) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Order> cq = cb.createQuery(Order.class);
Root<Order> order = cq.from(Order.class);
List<Predicate> predicates = new ArrayList<>();
predicates.add(cb.equal(order.get(Order_.status), status));
predicates.add(cb.between(order.get(Order_.createdAt), start, end));
if (customerName != null) {
Join<Order, Customer> customer = order.join(Order_.customer);
predicates.add(cb.like(
customer.get(Customer_.name),
"%" + customerName + "%"));
}
cq.where(predicates.toArray(new Predicate[0]));
return entityManager.createQuery(cq).getResultList();
}
// Generated SQL: identical to JPQL version
// Native SQL: full control, but manual mapping
@Query(value = """
SELECT o.* FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = :status
AND o.created_at BETWEEN :start AND :end
AND (:customerName IS NULL
OR c.name ILIKE '%' || :customerName || '%')
""", nativeQuery = true)
List<Order> findOrdersNative(@Param("status") String status,
@Param("start") LocalDateTime start,
@Param("end") LocalDateTime end,
@Param("customerName") String customerName);
// Note: ILIKE is PostgreSQL-specific (case-insensitive LIKE).
// JPQL and Criteria API cannot express this.
The JPQL version is readable. The Criteria version is verifiable at compile time (if you use the metamodel). The native version uses ILIKE for case-insensitive search, which neither JPQL nor Criteria can express.
The Fix
Use a decision framework:
Use JPQL when:
- The query is static (no conditional predicates based on runtime input)
- All required SQL features exist in JPQL
- The query is simple enough to read in one glance
Use Criteria API when:
- The query has dynamic predicates (search filters, optional parameters)
- You are building a reusable query builder for multiple callers
- You need compile-time validation (regulated or safety-critical systems)
Use Native SQL when:
- You need window functions, CTEs, recursive queries, or full-text search
- You need database-specific performance hints (
pg_hint_plan, index hints) - The JPQL version requires post-processing in Java that the database handles natively
- You are writing a reporting or analytics query
Spring Data Specifications: Criteria API Without the Pain
For dynamic queries with optional filters, Specifications wrap the Criteria API in a composable pattern:
// BETTER: Specifications for dynamic filters
public class OrderSpecs {
public static Specification<Order> hasStatus(OrderStatus status) {
return (root, query, cb) ->
cb.equal(root.get(Order_.status), status);
}
public static Specification<Order> createdBetween(
LocalDateTime start, LocalDateTime end) {
return (root, query, cb) ->
cb.between(root.get(Order_.createdAt), start, end);
}
public static Specification<Order> customerNameContains(
String name) {
if (name == null) return null;
return (root, query, cb) -> {
Join<Order, Customer> customer =
root.join(Order_.customer);
return cb.like(
cb.lower(customer.get(Customer_.name)),
"%" + name.toLowerCase() + "%");
};
}
}
// Usage:
List<Order> results = orderRepository.findAll(
Specification.where(OrderSpecs.hasStatus(OrderStatus.CONFIRMED))
.and(OrderSpecs.createdBetween(start, end))
.and(OrderSpecs.customerNameContains(searchTerm))
);
Specifications are the strongest argument for the Criteria API. Each specification is a reusable, testable predicate. Composition reads naturally. The verbose builder syntax is hidden inside each specification method.
The Cost Model
| Criterion | JPQL | Criteria API | Native SQL |
|---|---|---|---|
| Readability | High | Low | Medium |
| Type safety | None (string) | High (metamodel) | None (string) |
| Dynamic composition | Poor | Excellent | Poor |
| SQL feature coverage | ~70% | ~70% | 100% |
| Portability | Cross-database | Cross-database | Single database |
| Maintenance cost | Low | Medium | Medium-High |
| Generated SQL quality | Good | Good | You write it |
Performance differences between the three approaches are negligible for the same logical query. Hibernate generates the same SQL from JPQL and Criteria API. The performance difference lives in what query you can express, not in how you express it.
A codebase that uses JPQL for static queries, Specifications for dynamic search/filter endpoints, and native SQL for reporting and analytics is using each tool for its strength. A codebase that uses Criteria API for everything has traded readability for type safety that IDE autocompletion already provides. A codebase that uses native SQL for everything has abandoned the ORM while still paying its overhead.