← Back to the section

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

TaskTool
Fixed query over entitiesJPQL
Several optional filtersCriteria API
Window functions, ON CONFLICT, database specificsNative SQL
Repositories, pagination, projectionsSpring 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; @NamedQuery is parsed at startup, not on every call.
  • JOIN FETCH loads 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.
  • 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.