How Spring Data JPA derived methods, @Query, and query builders (Criteria API / Specifications) work, with diagrams and runnable examples.
Spring Boot doesn't run a single "query engine." Instead, it gives you several layers for talking to a database — each suited to a different level of complexity. This guide walks through how each layer works, when to reach for it, and how the type-safe query builders (Criteria API and Specifications) fit into the picture.
How a Query Travels Through a Spring Boot App
Before looking at syntax, it helps to see where queries actually live in the request lifecycle.
All query logic — no matter which technique you use — lives in the repository layer, sitting between your service code and Hibernate (the default JPA provider).
Spring Boot gives you four main ways to write a query there:
- Derived query methods — Spring writes the query for you from the method name.
-
@Querywith JPQL — you write a database-agnostic query yourself. -
@Querywith native SQL — you write raw SQL for the underlying database. -
Query builders (Criteria API /
Specification) — you build the query as Java objects at runtime.
1. Derived Query Methods
This is Spring Data JPA's signature feature: write a method signature on your repository interface, and Spring generates the SQL for you — no implementation, no annotation needed.
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
// SELECT * FROM employee WHERE last_name = ?
List<Employee> findByLastName(String lastName);
// SELECT * FROM employee WHERE age > ? ORDER BY last_name ASC
List<Employee> findByAgeGreaterThanOrderByLastNameAsc(int age);
// SELECT * FROM employee WHERE department = ? AND active = true
List<Employee> findByDepartmentAndActiveTrue(String department);
// SELECT COUNT(*) FROM employee WHERE department = ?
long countByDepartment(String department);
// SELECT * FROM employee WHERE email = ?
Optional<Employee> findByEmail(String email);
}
How Spring parses the method name
At startup, Spring Data inspects every method on your repository interface and breaks the name apart into a subject, predicates, and modifiers.
Spring recognizes a fixed vocabulary of keywords for this parsing: And, Or, Between, LessThan, GreaterThan, Like, In, NotNull, OrderBy, True/False, IgnoreCase, and more. As long as the method name maps unambiguously to entity fields, no query implementation is required at all.
Strengths: zero boilerplate, very readable for simple lookups.
Limits: method names get unwieldy past 2–3 conditions, and there's no room for dynamic/conditional logic.
2. @Query with JPQL
When a derived method name would get too long, or the query needs joins, aggregation, or a SELECT of specific columns, you write JPQL (Java Persistence Query Language) — SQL that operates on entity objects and their fields rather than tables and columns.
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
@Query("SELECT e FROM Employee e WHERE e.department.name = :dept AND e.salary > :minSalary")
List<Employee> findHighEarners(@Param("dept") String dept, @Param("minSalary") double minSalary);
@Query("SELECT e.department.name, COUNT(e), AVG(e.salary) " +
"FROM Employee e GROUP BY e.department.name")
List<Object[]> getDepartmentSalaryStats();
@Modifying
@Transactional
@Query("UPDATE Employee e SET e.active = false WHERE e.lastLogin < :cutoff")
int deactivateInactiveEmployees(@Param("cutoff") LocalDate cutoff);
}
Key points:
-
:paramNameis a named parameter, bound via@Param("paramName"). Positional parameters (?1,?2) also work but are harder to read. - JPQL queries reference entity class and field names (
Employee,e.department.name), not table or column names — this is what makes JPQL portable across databases. -
@Modifyingis required forUPDATE/DELETEqueries; without it Spring assumes aSELECT.
3. @Query with Native SQL
Sometimes you need database-specific features — window functions, full-text search, vendor-specific functions — that JPQL can't express. Set nativeQuery = true and write real SQL against your actual tables.
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
@Query(value = "SELECT * FROM employee e " +
"WHERE e.salary > (SELECT AVG(salary) FROM employee) " +
"ORDER BY e.salary DESC LIMIT :limit",
nativeQuery = true)
List<Employee> findTopEarners(@Param("limit") int limit);
}
Native queries trade portability for power: you're now tied to your specific database's SQL dialect, but you can use anything it supports.
| Derived Methods | JPQL (@Query) |
Native SQL (@Query) |
|
|---|---|---|---|
| Boilerplate | None | Low | Low |
| Complex joins/aggregation | Poor | Good | Excellent |
| Database portability | Full | Full | None |
| Dynamic/conditional filters | None | Limited | Limited |
| Best for | Simple lookups | Most custom queries | DB-specific features |
Query Builders: Building Queries as Java Objects
The three approaches above all involve writing a query as a string — either implied by a method name or typed out as JPQL/SQL. Strings have a problem: they're checked at runtime, not compile time. A typo in a field name only surfaces when that code path executes.
Query builders solve this by letting you construct a query as a tree of Java objects, which the JPA provider then turns into SQL. The two main options in the Spring ecosystem are the JPA Criteria API (built in) and Spring Data Specifications (a friendlier wrapper around it).
Criteria API: type-safe, fully programmatic
public List<Employee> searchEmployees(String department, Double minSalary, Boolean active) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaQuery<Employee> query = cb.createQuery(Employee.class);
Root<Employee> employee = query.from(Employee.class);
List<Predicate> predicates = new ArrayList<>();
if (department != null) {
predicates.add(cb.equal(employee.get("department"), department));
}
if (minSalary != null) {
predicates.add(cb.greaterThanOrEqualTo(employee.get("salary"), minSalary));
}
if (active != null) {
predicates.add(cb.equal(employee.get("active"), active));
}
query.where(predicates.toArray(new Predicate[0]));
return entityManager.createQuery(query).getResultList();
}
Notice there isn't a single SQL or JPQL string anywhere — every clause is a method call. The compiler catches type mismatches (e.g., comparing a String field to an int), and the conditions are only added if the corresponding filter was actually provided. This is exactly the kind of dynamic filtering that derived methods and static @Query strings can't do.
Spring Data Specifications: Criteria API, with less ceremony
Specification<T> wraps the Criteria API in a small functional interface, and lets your repository accept composable filter objects directly.
public interface EmployeeRepository extends JpaRepository<Employee, Long>,
JpaSpecificationExecutor<Employee> {
}
public class EmployeeSpecifications {
public static Specification<Employee> hasDepartment(String department) {
return (root, query, cb) -> department == null ? null :
cb.equal(root.get("department"), department);
}
public static Specification<Employee> salaryAtLeast(Double minSalary) {
return (root, query, cb) -> minSalary == null ? null :
cb.greaterThanOrEqualTo(root.get("salary"), minSalary);
}
public static Specification<Employee> isActive() {
return (root, query, cb) -> cb.isTrue(root.get("active"));
}
}
// Usage in a service:
Specification<Employee> spec = Specification
.where(EmployeeSpecifications.hasDepartment("Engineering"))
.and(EmployeeSpecifications.salaryAtLeast(90000.0))
.and(EmployeeSpecifications.isActive());
List<Employee> results = employeeRepository.findAll(spec);
This is the most common real-world pattern for search/filter endpoints — think an admin dashboard where the user can combine any subset of filters (department, salary range, status, hire date) and you don't want to write a separate query for every combination.
Choosing the Right Tool
| Scenario | Recommended approach | Why |
|---|---|---|
| "Find user by email" | Derived query method | Simple, self-documenting |
| Report with joins/aggregates |
@Query + JPQL |
Readable, DB-portable |
| Vendor-specific SQL feature |
@Query (nativeQuery = true) |
Full SQL dialect access |
| Multi-filter search/dashboard |
Specification / Criteria API |
Conditions added dynamically |
| Bulk update/delete |
@Query + @Modifying
|
Single round-trip to DB |
Best Practices
-
Start with derived methods, and only move to
@Queryonce the method name becomes hard to read (roughly 2–3 conditions is the practical ceiling). - Prefer JPQL over native SQL unless you specifically need a database feature JPQL can't express — it keeps your repository portable.
-
Always use named parameters (
:name+@Param) over positional ones (?1) — far easier to maintain as queries grow. -
Reach for
Specificationas soon as filters become optional/combinable — trying to do this with@Queryusually means a tangle ofCASE WHENor string concatenation, which is what query builders exist to avoid. -
Watch for the N+1 query problem: derived methods and JPQL fetching related entities lazily can trigger one query per row. Use
JOIN FETCHin JPQL or@EntityGraphto fetch related data in a single query. -
Use
PageableandSortas method parameters on any of these approaches (findByDepartment(String dept, Pageable pageable)) instead of manually buildingLIMIT/OFFSETlogic.
Summary
Spring Boot's query story isn't one mechanism — it's a spectrum from zero code (derived methods) to full programmatic control (Criteria API / Specifications), with @Query covering the large middle ground of custom-but-static queries. Most applications end up using all of them: derived methods for simple lookups, JPQL for reports, native SQL sparingly for special cases, and Specifications wherever users need to combine filters dynamically.















