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 and how complex it is.
Why JPQL if SQL already exists
Without an ORM you write SQL directly against tables: SELECT * FROM orders o JOIN users u ON o.user_id = u.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.user u WHERE u.email = :email";
List<Order> orders = em.createQuery(jpql, Order.class)
.setParameter("email", "user@example.com")
.getResultList();
Here Order is a Java class annotated with @Entity, and o.user is a field of type User, not a foreign key. If you rename a field or column through the mapping, the query changes in one place — in the annotation.
The short formula: JPQL = SQL syntax, but over the object model rather than over tables.
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.total > :min",
Order.class
);
query.setParameter("status", OrderStatus.ACTIVE);
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 frequently used queries there is @NamedQuery — it is declared at the class level and parsed at startup rather than on every call:
@Entity
@NamedQuery(
name = "Order.findByStatus",
query = "SELECT o FROM Order o WHERE o.status = :status"
)
public class Order { ... }
List<Order> active = em.createNamedQuery("Order.findByStatus", Order.class)
.setParameter("status", OrderStatus.ACTIVE)
.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 user's city, but user stays lazy
"SELECT o FROM Order o JOIN o.user u WHERE u.city = :city"
JOIN FETCH tells Hibernate: load the associated entity right now, in the same SQL query:
"SELECT o FROM Order o JOIN FETCH o.user u WHERE u.city = :city"
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 load everything into memory to apply the limit manually. 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 from the database, and loading the entire entity graph is wasteful. JPQL supports two approaches.
Constructor expression
public record OrderSummary(Long id, String userEmail, BigDecimal total) {}
List<OrderSummary> summaries = em.createQuery(
"SELECT new com.example.OrderSummary(o.id, u.email, o.total) " +
"FROM Order o JOIN o.user u WHERE o.status = :status",
OrderSummary.class
).setParameter("status", OrderStatus.ACTIVE).getResultList();
Hibernate calls the OrderSummary(Long, String, BigDecimal) constructor for each row. The result is a list of DTOs, not managed by the persistence context.
Interface projection (Spring Data)
If you work through Spring Data JPA, you can declare an interface and the repository will return a proxy:
public interface OrderSummary {
Long getId();
String getUserEmail(); // maps to o.user.email via naming convention
BigDecimal getTotal();
}
More on Spring Data projections 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("total"), minTotal));
}
if (city != null) {
Join<Order, User> user = root.join("user");
predicates.add(cb.equal(user.get("city"), city));
}
cq.where(predicates.toArray(new Predicate[0]));
cq.orderBy(cb.desc(root.get("createdAt")));
List<Order> result = em.createQuery(cq).getResultList();
Criteria API is type-safe (no strings with field names — if you use the metamodel) and guarantees a syntactically correct query. The metamodel is generated from @Entity classes via the JPA Annotation Processor and gives you access like Order_.status instead of the string "status".
The drawback is verbosity. For fixed queries JPQL reads better; Criteria API pays off when you have three or more optional filters.
Native queries — when SQL is unavoidable
Sometimes you need capabilities that JPQL lacks: window functions, RETURNING, INSERT ... ON CONFLICT, PostgreSQL-specific functions. For that there is createNativeQuery:
List<Object[]> rows = em.createNativeQuery(
"SELECT o.id, u.email, SUM(oi.price) " +
"FROM orders o " +
"JOIN users u ON o.user_id = u.id " +
"JOIN order_items oi ON oi.order_id = o.id " +
"WHERE o.created_at > :since " +
"GROUP BY o.id, u.email"
).setParameter("since", since)
.getResultList();
Hibernate runs the SQL as is and returns List<Object[]>. You can map the result to an entity via @SqlResultSetMapping or parse the array manually.
For frequent native queries, @NamedNativeQuery is convenient — it is declared next to the entity, similar to @NamedQuery.
Native queries are not cached by the second-level cache by default and do not take part in automatic flush — if there are unflushed changes before a native query, you may get stale data. Add an explicit em.flush() or check the FlushModeType setting.
How to read the execution plan
Any query — JPQL, Criteria, or native — ultimately turns into SQL. To understand whether the query uses an index and whether there is a full table scan, examine the plan with EXPLAIN ANALYZE. How to read the output is covered in the EXPLAIN and query optimization article.
When to choose what
| Task | Tool |
|---|---|
| Fixed query over entities | JPQL |
| Several optional filters | Criteria API |
Window functions, ON CONFLICT, 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.
- Native SQL is needed for capabilities JPQL lacks; flushing before it is manual.
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.