Backend Projects
8/17/2026
11 min read

JPQL Explained: Custom JPQL and Native SQL Queries in Spring Data JPA

JPQL Explained: Custom JPQL and Native SQL Queries in Spring Data JPA

JPQL stands for Java Persistence Query Language. It is the query language that Spring Data JPA and Hibernate use to fetch data, and it looks close enough to SQL that most developers assume they already know it. Then a query fails because they wrote a table name where an entity name belongs, and the difference stops being academic.

This guide covers what JPQL is, how it differs from native SQL, and how to write both inside a Spring Data JPA repository using the @Query annotation. Every example builds on one Employee Management System, so you can follow it end to end.

What Is JPQL?

JPQL is an object-oriented query language defined by the Jakarta Persistence specification. It queries your Java entities rather than your database tables.

That single sentence carries the whole distinction:

  • SQL operates on tables and columns

  • JPQL operates on entity classes and their fields

So if you have an Employee entity mapped to an employees table with a salary field mapped to a salary column, SQL asks for SELECT * FROM employees, and JPQL asks for SELECT e FROM Employee e. The persistence provider, usually Hibernate, translates the JPQL into SQL that matches whichever database you are connected to.

That translation step is the point. The same JPQL query runs against PostgreSQL, MySQL, or H2 without modification. In Stack Overflow's 2025 Developer Survey, 55.6% of all respondents and 58.2% of professional developers reported using PostgreSQL, against 40.5% and 39.6% for MySQL, so a codebase that outlives one database choice is a realistic concern rather than a hypothetical one.

A note on names. You will see HQL, JPQL, and EJB QL used almost interchangeably. HQL is Hibernate's own query language and predates the specification. JPQL is the standardised subset that every Jakarta Persistence provider must support. EJB QL is the older name for the same idea. Write JPQL and your queries stay portable across providers. Write HQL-only syntax and they do not.

When Derived Query Methods Stop Being Enough

Spring Data JPA generates queries from method names. findByDepartment(String department) needs no query at all, and for simple lookups that is the right tool.

The method-name approach breaks down when you need to:

  • Compare against a value rather than match it, such as salary above a threshold

  • Join several entities in one query

  • Select a few fields instead of a whole entity

  • Use a database function that has no JPQL equivalent

  • Write a reporting query that would produce a method name 90 characters long

At that point you reach for @Query, which lets you write the query yourself and keep it next to the repository method it belongs to.

The Employee Entity

Every example below uses this entity. Create it at src/main/java/com/masteringbackend/entity/Employee.java:

package com.masteringbackend.entity;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.Table;

@Entity
@Table(name = "employees")
public class Employee {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    private String name;

    private String department;

    private Double salary;

    // Getters and setters omitted for brevity
}

Two names matter for everything that follows. The entity name is Employee, which is what JPQL uses. The table name is employees, which is what native SQL uses. Mixing them up is the single most common JPQL error.

Writing Your First JPQL Query

Add the query to the repository interface at src/main/java/com/masteringbackend/repository/EmployeeRepository.java:

package com.masteringbackend.repository;

import com.masteringbackend.entity.Employee;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;

import java.util.List;

public interface EmployeeRepository extends JpaRepository<Employee, Long> {

    @Query("SELECT e FROM Employee e WHERE e.salary > :salary")
    List<Employee> findEmployeesWithHighSalary(@Param("salary") Double salary);
}

Read the query one piece at a time:

  • Employee is the entity class name, not the table name

  • e is an alias for the entity, the same way SQL aliases a table

  • e.salary is the Java field name, not the column name

  • :salary is a named parameter, bound by @Param("salary")

Spring translates this into SQL at startup and validates it, so a typo in a field name fails when the application context loads rather than when a user hits the endpoint.

Named Parameters vs Positional Parameters

JPQL supports both styles. Use named parameters:

@Query("SELECT e FROM Employee e WHERE e.department = :department AND e.salary > :salary")
List<Employee> findByDepartmentAndMinSalary(@Param("department") String department,
                                            @Param("salary") Double salary);

Positional parameters work but bind by argument order, which breaks silently the moment somebody reorders the method signature:

@Query("SELECT e FROM Employee e WHERE e.department = ?1 AND e.salary > ?2")
List<Employee> findByDepartmentAndMinSalaryPositional(String department, Double salary);

Named parameters cost a few extra characters and remove a class of bug. Prefer them.

Native SQL Queries

Sometimes portability is not what you need. A window function, a full-text index, a vendor-specific hint, or a hand-tuned reporting query all justify dropping to native SQL.

Set nativeQuery = true and write real SQL against real table names:

@Query(
    value = "SELECT * FROM employees WHERE department = :department",
    nativeQuery = true
)
List<Employee> findEmployeesByDepartment(@Param("department") String department);

Note what changed. The query now says employees, the table, not Employee, the entity. Spring no longer validates the query at startup, because it does not parse native SQL. A typo here surfaces at runtime.

JPQL vs Native SQL: The Practical Differences

FeatureJPQLNative SQL
Operates onEntity classes and fieldsTables and columns
Portable across databasesYesNo
Validated at application startupYesNo
Database-specific functionsNot availableAvailable
Vendor hints and window functionsLimitedFull access
Returns managed entities by defaultYesYes, when the result maps to an entity
Best forMost application queriesReporting, tuning, and vendor features

The rule most teams settle on: JPQL by default, native SQL when you can name the specific feature JPQL cannot reach.

Selecting Specific Columns

Fetching a whole entity to read one field wastes bandwidth and memory. JPQL can select individual fields:

@Query("SELECT e.name FROM Employee e")
List<String> findAllEmployeeNames();

For several fields, a DTO projection keeps the result typed. Create the DTO at src/main/java/com/masteringbackend/dto/EmployeeSummary.java, then construct it inside the query:

@Query("SELECT new com.masteringbackend.dto.EmployeeSummary(e.name, e.department) FROM Employee e")
List<EmployeeSummary> findEmployeeSummaries();

The constructor expression needs the fully qualified class name and a matching constructor on the DTO. This is the standard way to avoid loading entities you are not going to modify.

Sorting and Pagination

Sorting can live in the query:

@Query("SELECT e FROM Employee e ORDER BY e.salary DESC")
List<Employee> findAllSortedBySalary();

For pagination, leave sorting out of the JPQL and accept a Pageable instead, so callers control both:

@Query("SELECT e FROM Employee e WHERE e.salary > :salary")
Page<Employee> findHighEarners(@Param("salary") Double salary, Pageable pageable);

Native queries need a separate count query for pagination to work:

@Query(
    value = "SELECT * FROM employees WHERE salary > :salary",
    countQuery = "SELECT count(*) FROM employees WHERE salary > :salary",
    nativeQuery = true
)
Page<Employee> findHighEarnersNative(@Param("salary") Double salary, Pageable pageable);

Leaving out countQuery on a paginated native query is a frequent source of confusing runtime errors.

Updates and Deletes With @Modifying

@Query runs read queries by default. Writes need two extra annotations:

@Modifying
@Transactional
@Query("UPDATE Employee e SET e.salary = e.salary * 1.1 WHERE e.department = :department")
int giveDepartmentRaise(@Param("department") String department);

@Modifying tells Spring the query changes data, @Transactional gives it a transaction, and the int return value is the number of affected rows. Bulk updates written this way bypass the persistence context, so entities already loaded in memory will hold stale values until the context is cleared.

Using the Query From a Service and Controller

The repository method is the only part that changes. The layers above stay ordinary.

Create the service at src/main/java/com/masteringbackend/service/EmployeeService.java:

package com.masteringbackend.service;

import com.masteringbackend.entity.Employee;
import com.masteringbackend.repository.EmployeeRepository;
import org.springframework.stereotype.Service;

import java.util.List;

@Service
public class EmployeeService {

    private final EmployeeRepository employeeRepository;

    public EmployeeService(EmployeeRepository employeeRepository) {
        this.employeeRepository = employeeRepository;
    }

    public List<Employee> getHighSalaryEmployees(Double salary) {
        return employeeRepository.findEmployeesWithHighSalary(salary);
    }
}

Then expose it at src/main/java/com/masteringbackend/controller/EmployeeController.java:

package com.masteringbackend.controller;

import com.masteringbackend.entity.Employee;
import com.masteringbackend.service.EmployeeService;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

import java.util.List;

@RestController
@RequestMapping("/employees")
public class EmployeeController {

    private final EmployeeService employeeService;

    public EmployeeController(EmployeeService employeeService) {
        this.employeeService = employeeService;
    }

    @GetMapping("/high-salary/{salary}")
    public List<Employee> getHighSalaryEmployees(@PathVariable Double salary) {
        return employeeService.getHighSalaryEmployees(salary);
    }
}

Call it and you get every employee earning above the threshold:

curl http://localhost:8080/employees/high-salary/70000

If the @PathVariable binding is new to you, our guide to path variables in Spring Boot covers it in depth.

Common JPQL Mistakes

Using the table name instead of the entity name. SELECT e FROM employees e fails. JPQL wants Employee.

Using column names instead of field names. If a field firstName maps to a column first_name, JPQL needs e.firstName.

Writing SELECT *. JPQL has no *. Select the alias: SELECT e FROM Employee e.

Forgetting countQuery on a paginated native query. Pagination cannot work out the total without it.

Expecting @Modifying bulk updates to refresh loaded entities. They do not. Clear the persistence context or reload.

Assuming native queries are faster. They are not inherently faster. They are more controllable. Most slow JPA queries are slow because of the N+1 problem or a missing index, and neither is fixed by changing query language.

Performance Notes Worth Knowing

Query language is rarely the bottleneck. Three things usually are:

  1. The N+1 problem. One query fetches 100 employees, then 100 more queries fetch each employee's department. Use JOIN FETCH in JPQL to load them in one round trip.

  2. Fetching entities you only read. DTO projections avoid the cost of managing objects you are going to serialise and discard.

  3. Missing indexes. No query language compensates for a full table scan. Check the execution plan before rewriting the query.

For read-heavy endpoints, caching the result is often a bigger win than tuning the query. Our guide to caching in Java with Spring Boot and Redis walks through that setup.

Best Practices

  • Default to JPQL. Reach for native SQL when you can name the feature JPQL lacks

  • Use named parameters, never positional

  • Use DTO projections for read-only queries

  • Keep queries short enough to read in one screen. Move anything longer into a database view

  • Pair @Modifying with @Transactional every time

  • Let Pageable handle sorting rather than hard-coding ORDER BY

  • Never build a query by concatenating user input. Parameters exist for this reason

Frequently Asked Questions

What Does JPQL Stand For?

Java Persistence Query Language. It is the standard query language defined by the Jakarta Persistence specification, previously known as the Java Persistence API.

What Is the Difference Between JPQL and Native Query?

JPQL queries entity classes and Java field names, and the persistence provider translates it into SQL for whichever database you are using. A native query is SQL written directly against table and column names, and it runs unchanged. JPQL is portable and validated at startup. Native SQL gives you database-specific features and is not validated until it runs.

Is JPQL the Same as HQL?

No, though they overlap heavily. HQL is Hibernate's own query language and supports syntax outside the specification. JPQL is the standardised subset that every Jakarta Persistence provider implements. Every valid JPQL query is valid HQL. The reverse is not true.

Is JPQL Faster Than Native SQL?

Neither is faster by default. Both end up as SQL executed by the same database. Native SQL lets you hand-tune a query that JPQL cannot express, which can be faster in specific cases. Most performance problems in JPA applications come from the N+1 problem or missing indexes rather than from the query language.

Can I Use JPQL Without Spring Data JPA?

Yes. JPQL is part of the Jakarta Persistence specification and works through an EntityManager in any compliant setup. Spring Data JPA's @Query annotation is a convenience layer over the same mechanism.

How Do I Join Tables in JPQL?

Join on the entity relationship, not on a foreign key column: SELECT e FROM Employee e JOIN e.department d WHERE d.name = :name. Add JOIN FETCH instead of JOIN when you want the related entity loaded in the same query.

Summary

JPQL queries your entities. Native SQL queries your tables. That one distinction explains most of the errors developers hit when they start writing @Query, and it also explains when each is the right choice.

Use JPQL for the queries that make up the bulk of an application, because it is portable, validated at startup, and easier to read next to the entities it references. Use native SQL when you need something the specification does not cover, and accept that you have traded portability for control.

The concepts here, custom queries, projections, pagination, and bulk updates, show up in every production Spring Data JPA codebase. If you want to practise them on something larger than a snippet, our Java backend projects give you real schemas to query against, and the Java backend development courses roundup covers where to go for the wider Spring curriculum.

Tags

Enjoyed this article?

Subscribe to our newsletter for more backend engineering insights and tutorials.