JPA offers three ways to query the database: JPQL, Criteria API, and native SQL. Each solves its own problem: the choice depends on how static the query is.
JPQL and Criteria do not reach the database as written: Hibernate translates entity and field names into table and column names. That is why renaming a field is a fix in the mapping while the queries stay as they are. Native SQL goes past that translation — the table names in it are yours, and before running it Hibernate flushes the whole persistence context to the database.
Why JPQL if SQL already exists
Without an ORM you write SQL directly against tables: SELECT * FROM orders o JOIN customer c ON c.id = o.customer_id. This works, but it ties your logic to the schema: rename a column and you're fixing queries all over the code.
JPQL (Java Persistence Query Language) works not with tables, but with entities and their fields. A query looks like this:
String jpql = "SELECT o FROM Order o JOIN o.customer c WHERE c.email = :email";
List<Order> orders = em.createQuery(jpql, Order.class)
.setParameter("email", "buyer@example.com")
.getResultList();
Here Order is a Java class annotated with @Entity, and o.customer is a field of type Customer, not a foreign key. Rename a field or column through the mapping and the query changes in one place — in the annotation.
Parameters and safety
Never inject values into a query string via concatenation — that's SQL injection. Always use named parameters:
TypedQuery<Order> query = em.createQuery(
"SELECT o FROM Order o WHERE o.status = :status AND o.totalAmount > :min",
Order.class
);
query.setParameter("status", OrderStatus.PAID);
query.setParameter("min", BigDecimal.valueOf(1000));
List<Order> result = query.getResultList();
TypedQuery<T> is the typed version of Query; it returns List<T> with no casting.
For frequent queries there is @NamedQuery — declared at the class level and parsed at startup, not on every call:
@Entity
@NamedQuery(
name = "Order.findByStatus",
query = "SELECT o FROM Order o WHERE o.status = :status"
)
public class Order { ... }
List<Order> paid = em.createNamedQuery("Order.findByStatus", Order.class)
.setParameter("status", OrderStatus.PAID)
.getResultList();
JOIN and JOIN FETCH
A regular JOIN in JPQL filters the result but does not load the associated entities — they stay as lazy proxies:
// filter by the customer's status, but customer stays lazy
"SELECT o FROM Order o JOIN o.customer c WHERE c.status = :status"
JOIN FETCH tells Hibernate: load the associated entity right now, in the same SQL query:
"SELECT o FROM Order o JOIN FETCH o.customer c WHERE c.status = :status"
This is the main tool for fighting the N+1 problem — more detail in the article on N+1.
An important limitation: you cannot combine JOIN FETCH of a collection with setMaxResults() — Hibernate will warn in the logs and apply the limit in memory. If you need both pagination and collection loading, use two queries or @BatchSize.
Projections: fetching less than the whole entity
Sometimes you need a few columns and loading the whole entity graph is wasteful. There are two approaches.
Constructor expression
public record OrderSummary(Long id, String customerEmail, BigDecimal total) {}
List<OrderSummary> summaries = em.createQuery(
"SELECT new com.example.OrderSummary(o.id, c.email, o.totalAmount) " +
"FROM Order o JOIN o.customer c WHERE o.status = :status",
OrderSummary.class
).setParameter("status", OrderStatus.PAID).getResultList();
Hibernate calls the constructor for each row. The result is a list of DTOs, not managed by the persistence context.
Interface projection (Spring Data)
With Spring Data JPA you can declare an interface and the repository will return a proxy:
public interface OrderSummary {
Long getId();
BigDecimal getTotalAmount();
CustomerView getCustomer(); // a nested projection, not getCustomerEmail()
interface CustomerView {
String getEmail();
}
}
A getter is resolved against the properties of the entity itself: getCustomerEmail() is not found on Order, which only has a customer field. A nested field is taken through a nested interface, as above, or through @Value("#{target.customer.email}"). More in the Spring Data JPA article.
Criteria API — dynamic queries
JPQL is a string. Assembling a string with conditions through if branches is awkward and dangerous. Criteria API builds the query programmatically:
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Order> cq = cb.createQuery(Order.class);
Root<Order> root = cq.from(Order.class);
List<Predicate> predicates = new ArrayList<>();
if (status != null) {
predicates.add(cb.equal(root.get("status"), status));
}
if (minTotal != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get("totalAmount"), minTotal));
}
if (email != null) {
Join<Order, Customer> customer = root.join("customer");
predicates.add(cb.equal(customer.get("email"), email));
}
cq.where(predicates.toArray(new Predicate[0]));
cq.orderBy(cb.desc(root.get("createdAt")));
List<Order> result = em.createQuery(cq).getResultList();
Conditions pile up in a list, get joined with AND, and the values travel separately. The same skeleton in plain Java:
live example
import java.util.ArrayList;
import java.util.List;
public class DynamicWhere {
static void show(String status, Integer minTotal, String email) {
List<String> where = new ArrayList<>();
List<Object> params = new ArrayList<>();
if (status != null) { where.add("o.status = ?"); params.add(status); }
if (minTotal != null) { where.add("o.total_amount >= ?"); params.add(minTotal); }
if (email != null) { where.add("c.email = ?"); params.add(email); }
String sql = "select o.* from orders o join customer c on c.id = o.customer_id";
System.out.println(where.isEmpty() ? sql : sql + " where " + String.join(" and ", where));
System.out.println(" params: " + params);
}
public static void main(String[] args) {
show(null, null, null);
show("PAID", null, null);
show("PAID", 1000, "buyer@example.com");
}
}
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Three sets of filters, three different queries, and no value ever ends up inside the text. Criteria API does the same with tree nodes instead of strings.
It guarantees a syntactically correct query and is type-safe — if you use the metamodel instead of the string "status": it is generated from @Entity classes by the JPA Annotation Processor and gives access like Order_.status.
The drawback is verbosity: for fixed queries JPQL reads better, while Criteria API pays off with three or more optional filters.
Native queries — when SQL is unavoidable
Sometimes you need capabilities that JPQL lacks: RETURNING, INSERT ... ON CONFLICT, PostgreSQL-specific functions. Window functions are no longer on that list — HQL has understood them since Hibernate 6.
The query itself is ordinary SQL over tables, not entities:
live example
SELECT o.id, c.email, sum(oi.unit_price * oi.quantity) AS items_total
FROM orders o
JOIN customer c ON c.id = o.customer_id
JOIN order_items oi ON oi.order_id = o.id
WHERE o.created_at > '2024-01-01'
GROUP BY o.id, c.email
Run
Running examples is part of paid access. There the same code runs inside the article: editor, run and check next to the paragraph. Three free days →
Hibernate takes it through createNativeQuery:
Query query = em.createNativeQuery(sql);
List<Object[]> rows = query.getResultList();
The result is a List<Object[]>: parse it by hand or map it to an entity through @SqlResultSetMapping. For frequent queries there is @NamedNativeQuery, the counterpart of @NamedQuery.
The surprise is not the result but what happens before the query. Hibernate does not parse foreign SQL and does not know which tables it touches, so through the EntityManager it flushes the whole persistence context before every native query. You can narrow that down by naming the affected tables through addSynchronizedEntityClass(Order.class).
The other side: a native UPDATE goes past the context, and loaded entities keep their old values — em.clear() is needed after it.
How to read the execution plan
Any of the three ends up as SQL and gets slow for database reasons, not ORM ones — so what you read is the plan, via EXPLAIN ANALYZE.
When to choose what
| Task | Tool |
|---|---|
| Fixed query over entities | JPQL |
| Several optional filters | Criteria API |
ON CONFLICT, RETURNING, database specifics | Native SQL |
| Repositories, pagination, projections | Spring Data JPA (on top of JPQL/Native) |
In short
- JPQL works over entities, not tables — renaming a field changes in one place.
TypedQuery<T>eliminates casting;@NamedQueryis parsed at startup, not on every call.JOIN FETCHloads associated entities in a single SQL — the primary way to avoid N+1.- A constructor expression (
new ClassName(...)) returns DTOs not managed by the persistence context. - Criteria API is the choice for dynamic filters; verbose, but type-safe.
- Before a native query Hibernate flushes the whole context, and a native
UPDATEgoes past the context —em.clear()is needed after it.
What to read next
- The N+1 problem and JOIN FETCH — how JPQL queries relate to the number of SQL calls to the database.
- Entity mapping — how annotations determine exactly what ends up in the query.
- Caching in Hibernate — what is and isn't cached for different query types.
- Spring Data JPA — repositories, projections, and derived queries on top of JPA.