DEV Community

Cover image for Spring Boot Queries and Query Builders: A Practical Guide
Victor Mithamo
Victor Mithamo

Posted on

Spring Boot Queries and Query Builders: A Practical Guide

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.

Diagram: Controller calls Service, which calls Repository (JpaRepository / @Query / Specification), which Hibernate translates from JPQL to SQL, which hits the Database

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:

  1. Derived query methods — Spring writes the query for you from the method name.
  2. @Query with JPQL — you write a database-agnostic query yourself.
  3. @Query with native SQL — you write raw SQL for the underlying database.
  4. 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);
}
Enter fullscreen mode Exit fullscreen mode

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.

Diagram: the method name findByLastNameAndActiveTrue broken into chips labeled action, keyword, entity field, join keyword, entity field, literal condition, resolving to a JPQL SELECT statement

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);
}
Enter fullscreen mode Exit fullscreen mode

Key points:

  • :paramName is 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.
  • @Modifying is required for UPDATE/DELETE queries; without it Spring assumes a SELECT.

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);
}
Enter fullscreen mode Exit fullscreen mode

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).

Diagram: CriteriaBuilder feeds into CriteriaQuery, which feeds into Predicates, which feed into a TypedQuery that produces SQL

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();
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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 @Query once 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 Specification as soon as filters become optional/combinable — trying to do this with @Query usually means a tangle of CASE WHEN or 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 FETCH in JPQL or @EntityGraph to fetch related data in a single query.
  • Use Pageable and Sort as method parameters on any of these approaches (findByDepartment(String dept, Pageable pageable)) instead of manually building LIMIT/OFFSET logic.

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.

Top comments (0)