When to choose JPQL or native SQL, joins between unrelated entities, native queries in projections, constructor expressions, calling stored procedures, how JPQL maps to entity attributes, collection parameters, bulk update/delete with JPQL, paginating native queries, native-query limits in Spring Data, schema-evolution and portability risks, performance differences, and when to move to the Criteria API.
Published September 25, 2026
The core idea: JPQL speaks the object model, native SQL speaks the database. Pick the one that expresses the query best, then check the generated SQL and execution plan. Performance comes from the SQL and the indexes, not from the query language.
Short answer:
LATERAL joins;INSERT … ON CONFLICT/MERGE;Keep native SQL behind repository methods, and cover it with integration tests on the real database (Testcontainers).
Short answer: Yes, since JPA 2.2 / Hibernate 5.1 (entity joins). You can join entities without a mapped association, using an explicit ON clause:
@Query("""
select new com.shop.dto.CustomerSpend(c.id, c.name, sum(p.amount))
from Customer c
join Payment p on p.customerEmail = c.email -- no mapped association between them
where p.status = 'SETTLED'
group by c.id, c.name
""")
List<CustomerSpend> spendByCustomer();
In older JPA versions, you had to use theta-style joins in the WHERE clause (from A a, B b where a.x = b.y, which is an inner join only).
Short answer: Yes:
select o.id as id, o.total as total …).@SqlResultSetMapping with @ConstructorResult, or Hibernate's result transformers. Spring Data 3.x can map native results to DTOs in some cases, and JdbcClient or JdbcTemplate with a RowMapper or DataClassRowMapper (records) is often simplest.Tuple or Object[] results, mapped manually.Beware type mismatches (for example, BigInteger vs Long for counts), which are vendor-dependent.
Short answer: select new fully.qualified.Dto(expr1, expr2, …) tells JPA to instantiate the DTO for each row, with the matching constructor. The result objects are not managed entities: there's no dirty checking, no persistence-context memory, and only the selected columns are fetched. That makes them ideal for read models.
public record OrderRow(UUID id, String customerName, BigDecimal total, OrderStatus status) {}
@Query("select new com.shop.dto.OrderRow(o.id, c.name, o.total, o.status) " +
"from Order o join o.customer c where o.createdAt >= :since order by o.createdAt desc")
List<OrderRow> recentRows(@Param("since") Instant since);
The requirements:
Long vs Integer, and numeric promotion from aggregates like sum/count).Short answer: JPQL itself can't call procedures. Use JPA's stored-procedure support:
@NamedStoredProcedureQuery on an entity, plus a @Procedure repository method in Spring Data;EntityManager.createStoredProcedureQuery("proc_name"), registering IN/OUT/REF_CURSOR parameters;CALL or SELECT function(...), through a native query or SimpleJdbcCall/JdbcClient.public interface InvoiceRepository extends JpaRepository<Invoice, Long> {
@Procedure(procedureName = "close_period")
int closePeriod(@Param("period_id") long periodId); // OUT parameter mapped to the return value
}
Key points to cover:
Short answer: JPQL is written against the entity model: entity names (@Entity(name), defaulting to the class name) and attribute paths (o.customer.address.city), not the table and column names. The provider translates it using the mapping metadata: @Table, @Column, and embeddables, including joins implied by path navigation. So:
@Column(name = ...), and the queries stay valid;@Query JPQL is validated at startup against the metamodel (typos fail fast);Short answer: Bind a Collection to an IN clause:
@Query("select o from Order o where o.status in :statuses and o.region in :regions")
List<Order> find(@Param("statuses") Collection<OrderStatus> statuses, @Param("regions") List<String> regions);
The caveats:
IN ()). Guard it in code, or use a Specification.IN, and SQL Server about 2,100 parameters in total) and pollute the plan cache, because every size is a different SQL statement. Use batching, hibernate.query.in_clause_parameter_padding=true (which pads to powers of two), a temporary table, or = ANY(:array) on Postgres (natively).Short answer: Yes: bulk JPQL with @Modifying @Query("update Order o set o.status = :s where …") (or executeUpdate()). The caveats:
@Modifying(clearAutomatically = true, flushAutomatically = true).@PreUpdate, entity listeners and cascades don't run, and @Version isn't incremented automatically (increment it in the query, if you rely on it).It's great for batch maintenance (expiring carts, status transitions), and much faster than loading entities.
Short answer: Yes. Spring Data applies Pageable to native queries by appending LIMIT/OFFSET (dialect-specific), but for Page<T> you must supply a countQuery, because it can't reliably derive the count from arbitrary SQL:
@Query(value = "select * from orders where customer_id = :cid order by created_at desc",
countQuery = "select count(*) from orders where customer_id = :cid",
nativeQuery = true)
Page<Order> findPage(@Param("cid") UUID customerId, Pageable pageable);
The caveats:
Sort from the Pageable may not work with complex native SQL. Put the ordering in the query, and allow-list any sort fields.where (created_at, id) < (:lastTs, :lastId) order by created_at desc, id desc limit :n).Short answer:
Sort is limited. Specifications and Examples don't work with native queries.@SqlResultSetMapping, or entity-shaped results. Nested DTOs are awkward.Short answer:
LIMIT vs FETCH FIRST, JSON operators, ILIKE) locks you into one database, and makes tests on H2 misleading.Mitigations:
Short answer: Not inherently. JPQL is translated once (and cached) into SQL, so the database executes SQL either way. The differences come from:
Measure it: enable SQL logging with bind parameters, run EXPLAIN ANALYZE, and compare the timings. Optimise the SQL and the indexes, not the query language.
Short answer: When the query is built dynamically at runtime (optional filters, user-chosen sorting, conditional joins), and string concatenation would be fragile or unsafe. The Criteria API (or Specifications or Querydsl on top of it) gives type-safe, composable predicates, especially with the JPA static metamodel (Order_.status). Stay with JPQL for static queries: it's more readable. Many teams keep JPQL for fixed queries, and Specifications or Querydsl for search endpoints.
Q: What does Hibernate 6 add to HQL?
A: Set operations (union), CTEs (with), window functions, lateral joins, insert … select, limit/offset, richer functions, and better typed results. Many former native-only queries can now stay in HQL, and remain portable across supported dialects.
Q: How do you see the SQL, with its bind values?
A: In development: logging.level.org.hibernate.SQL=DEBUG, and org.hibernate.orm.jdbc.bind=TRACE (Hibernate 6), or datasource-proxy/p6spy for formatted SQL with values. In production, log only slow queries, or use APM traces.
Q: What is the JPA static metamodel?
A: Generated classes (Order_) with typed attributes (SingularAttribute<Order, OrderStatus> status), produced by an annotation processor. They make Criteria queries and Specifications type-safe and refactoring-friendly.
Q: How do you prevent SQL injection in JPQL and native queries?
A: Always bind parameters (:param), and never concatenate user input into query strings. For dynamic ORDER BY columns (which can't be bound), map the user's input against an allow-list.