Spring Data JPA @Query: JPQL and Native Queries

Introduction

This tutorial shows how to write queries by hand in Spring Data JPA with the @Query annotation, in JPQL - the query language of JPA - and in native SQL. We will start with why derived query methods are sometimes not enough, and what JPQL is. We will then write JPQL queries with named parameters, text matching and sorting, return aggregated results into a record, and page through a query with Pageable. Next we will write native SQL queries and see when they are needed, run bulk updates and deletes with @Modifying, and see that Spring Data checks JPQL when the application starts. Lastly, we will compare JPQL with native SQL and summarise how to choose between all three kinds of query.

(This tutorial continues from Spring Data JPA Derived Query Methods ExplainedClick to read tutorial: Spring Data JPA Derived Query Methods Explained and uses the same Book entity and the H2 database from Spring Data JPA IntroductionClick to read tutorial: Spring Data JPA Introduction: Entities and Repositories.)

When derived queries are not enough

Derived query methods are ideal for simple lookups, but their names become unreadable with more than two or three conditions, and they cannot express grouping, aggregation, subqueries or conditions combined with brackets. For those, you write the query yourself and put it on a repository method with @Query. The method name is then free - choose one that explains the purpose - and Spring Data uses your query instead of deriving one.

JPQL in a few lines

JPQL (Jakarta Persistence Query Language) looks like SQL, but it works on entities and their fields, not on tables and columns. You write select b from Book b where b.publishedYear >= :year - Book is the entity class and publishedYear its field - and Hibernate translates it into SQL for whichever database is in use. That makes JPQL portable between databases, and it can be checked against the entities, as we will see. Parameters are written :name and bound to method parameters annotated with @Param (or ?1, ?2 by position, which is easier to get wrong).

A repository with @Query methods

The repository below declares JPQL queries for filtering, text search, aggregation and paging, native queries, and bulk updates. The Book entity is unchanged, and AuthorStats is a record that receives the aggregated results -
Book.java - the entity, as before
//Book.java
package com.javabrahman.data.query;

import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import java.math.BigDecimal;

@Entity
public class Book {
  @Id
  @GeneratedValue(strategy = GenerationType.IDENTITY)
  private Long id;
  private String title;
  private String author;
  private int publishedYear;
  private BigDecimal price;

  protected Book() {
  }

  public Book(String title, String author, int publishedYear, BigDecimal price) {
    this.title = title;
    this.author = author;
    this.publishedYear = publishedYear;
    this.price = price;
  }

  public Long getId() { return id; }
  public String getTitle() { return title; }
  public String getAuthor() { return author; }
  public int getPublishedYear() { return publishedYear; }
  public BigDecimal getPrice() { return price; }

  @Override
  public String toString() {
    return title + " (" + author + ", " + publishedYear + ", $" + price + ")";
  }
}
AuthorStats.java - a record that receives aggregated results
//AuthorStats.java
package com.javabrahman.data.query;

public record AuthorStats(String author, long books, double averagePrice) {
}
BookRepository.java - queries written with @Query
//BookRepository.java
package com.javabrahman.data.query;

import java.math.BigDecimal;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.domain.Sort;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.transaction.annotation.Transactional;

public interface BookRepository extends JpaRepository<Book, Long> {

  // JPQL with named parameters
  @Query("select b from Book b where b.author = :author and b.publishedYear >= :fromYear order by b.price")
  List<Book> recentBooksBy(@Param("author") String author, @Param("fromYear") int fromYear);

  // JPQL text search: case-insensitive "contains", with a caller-supplied Sort
  @Query("select b from Book b where lower(b.title) like lower(concat('%', :text, '%'))")
  List<Book> searchTitles(@Param("text") String text, Sort sort);

  // JPQL aggregation into a record, with a constructor expression
  @Query("""
      select new com.javabrahman.data.query.AuthorStats(b.author, count(b), avg(b.price))
      from Book b
      group by b.author
      having count(b) > :minBooks
      order by count(b) desc, b.author
      """)
  List<AuthorStats> authorsWithMoreThan(@Param("minBooks") long minBooks);

  // JPQL with paging: the count query for the page is derived from this query
  @Query("select b from Book b where b.price >= :minPrice order by b.price desc, b.id")
  Page<Book> expensiveBooks(@Param("minPrice") BigDecimal minPrice, Pageable pageable);

  // A subquery, which no method name can express
  @Query("select b from Book b where b.price > (select avg(x.price) from Book x) order by b.price desc")
  List<Book> aboveAveragePrice();

  // Native SQL, with positional parameters
  @Query(value = "select * from book where published_year between ?1 and ?2 order by published_year, title", nativeQuery = true)
  List<Book> publishedBetween(int from, int to);

  // Native SQL with a derived table: the decade of each book, as text, then a count per decade
  @Query(value = "select decade, count(*) as books from "
      + "(select concat(floor(published_year / 10) * 10, 's') as decade from book) as t "
      + "group by decade order by decade", nativeQuery = true)
  List<Object[]> booksPerDecade();

  // Bulk update and delete: one statement each, no entities loaded
  @Modifying(clearAutomatically = true)
  @Transactional
  @Query("update Book b set b.price = b.price * :factor where b.author = :author")
  int adjustPrices(@Param("author") String author, @Param("factor") BigDecimal factor);

  @Modifying
  @Transactional
  @Query("delete from Book b where b.publishedYear < :year")
  int deleteOlderThan(@Param("year") int year);
}
QueryApp.java - runs the queries
//QueryApp.java
package com.javabrahman.data.query;

import java.math.BigDecimal;
import java.util.Collection;
import java.util.List;
import org.springframework.boot.CommandLineRunner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;

@SpringBootApplication
public class QueryApp {

  @Bean
  CommandLineRunner demo(BookRepository r) {
    return args -> {
      r.saveAll(List.of(
          new Book("Effective Java", "Joshua Bloch", 2018, new BigDecimal("45.99")),
          new Book("Java Puzzlers", "Joshua Bloch", 2005, new BigDecimal("29.99")),
          new Book("Java Concurrency in Practice", "Brian Goetz", 2006, new BigDecimal("49.99")),
          new Book("Clean Code", "Robert C. Martin", 2008, new BigDecimal("37.49")),
          new Book("Clean Architecture", "Robert C. Martin", 2017, new BigDecimal("34.99")),
          new Book("The Clean Coder", "Robert C. Martin", 2011, new BigDecimal("31.99")),
          new Book("Refactoring", "Martin Fowler", 2018, new BigDecimal("47.99")),
          new Book("Spring in Action", "Craig Walls", 2022, new BigDecimal("49.99")),
          new Book("Domain-Driven Design", "Eric Evans", 2003, new BigDecimal("59.99"))));

      show("recentBooksBy(\"Robert C. Martin\", 2010)", r.recentBooksBy("Robert C. Martin", 2010));
      show("searchTitles(\"CLEAN\", Sort.by(\"publishedYear\"))", r.searchTitles("CLEAN", Sort.by("publishedYear")));
      System.out.println("authorsWithMoreThan(1):");
      r.authorsWithMoreThan(1).forEach(s -> System.out.printf("    %s: %d books, average price $%.2f%n", s.author(), s.books(), s.averagePrice()));

      Page<Book> page = r.expensiveBooks(new BigDecimal("40"), PageRequest.of(0, 2));
      System.out.println("expensiveBooks(40), page 1 of size 2: total " + page.getTotalElements() + " books, " + page.getTotalPages() + " pages");
      page.forEach(b -> System.out.println("    " + b));
      show("aboveAveragePrice()", r.aboveAveragePrice());

      show("publishedBetween(2005, 2008) - native", r.publishedBetween(2005, 2008));
      System.out.println("booksPerDecade() - native:");
      r.booksPerDecade().forEach(row -> System.out.println("    " + row[0] + ": " + row[1]));

      int updated = r.adjustPrices("Robert C. Martin", new BigDecimal("0.90"));
      System.out.println("adjustPrices(\"Robert C. Martin\", 0.90) updated " + updated + " rows");
      show("after the update", r.recentBooksBy("Robert C. Martin", 2000));
      System.out.println("deleteOlderThan(2006) deleted " + r.deleteOlderThan(2006) + " rows, " + r.count() + " left");
    };
  }

  private static void show(String call, Collection<Book> books) {
    System.out.println(call + ":");
    books.forEach(b -> System.out.println("    " + b));
  }

  public static void main(String[] args) {
    SpringApplication.run(QueryApp.class, args);
  }
}
 OUTPUT of the above code
recentBooksBy("Robert C. Martin", 2010):
    The Clean Coder (Robert C. Martin, 2011, $31.99)
    Clean Architecture (Robert C. Martin, 2017, $34.99)
searchTitles("CLEAN", Sort.by("publishedYear")):
    Clean Code (Robert C. Martin, 2008, $37.49)
    The Clean Coder (Robert C. Martin, 2011, $31.99)
    Clean Architecture (Robert C. Martin, 2017, $34.99)
authorsWithMoreThan(1):
    Robert C. Martin: 3 books, average price $34.82
    Joshua Bloch: 2 books, average price $37.99
expensiveBooks(40), page 1 of size 2: total 5 books, 3 pages
    Domain-Driven Design (Eric Evans, 2003, $59.99)
    Java Concurrency in Practice (Brian Goetz, 2006, $49.99)
aboveAveragePrice():
    Domain-Driven Design (Eric Evans, 2003, $59.99)
    Java Concurrency in Practice (Brian Goetz, 2006, $49.99)
    Spring in Action (Craig Walls, 2022, $49.99)
    Refactoring (Martin Fowler, 2018, $47.99)
    Effective Java (Joshua Bloch, 2018, $45.99)
publishedBetween(2005, 2008) - native:
    Java Puzzlers (Joshua Bloch, 2005, $29.99)
    Java Concurrency in Practice (Brian Goetz, 2006, $49.99)
    Clean Code (Robert C. Martin, 2008, $37.49)
booksPerDecade() - native:
    2000s: 4
    2010s: 4
    2020s: 1
adjustPrices("Robert C. Martin", 0.90) updated 3 rows
after the update:
    The Clean Coder (Robert C. Martin, 2011, $28.79)
    Clean Architecture (Robert C. Martin, 2017, $31.49)
    Clean Code (Robert C. Martin, 2008, $33.74)
deleteOlderThan(2006) deleted 2 rows, 7 left

Explanation of the output

  • Named parameters: :author and :fromYear were bound to the method parameters named in @Param. (With the -parameters compiler option, which Spring Boot's build plugins switch on, @Param can be left out when the parameter names match.)
  • Sorting and a Sort parameter: searchTitles has no order by in the query; the caller's Sort was appended, so the same query serves any order. The lower(...) like lower(concat('%', :text, '%')) pattern makes a case-insensitive "contains" search; the search for CLEAN found all three books with Clean in the title.
  • Aggregation: select new ...AuthorStats(...) is a constructor expression - JPQL calls the record's constructor with each row's values. Types must fit: count() is a long and avg() a double. having count(b) > :minBooks kept only authors with more than one book.
  • Paging: a Pageable parameter and a Page return type make Spring Data add the limit and offset, and run a second count query - derived from the main query - for the totals: 5 books cost at least $40, so with a page size of 2 there are 3 pages, and the first page holds the two most expensive.
  • A subquery found the books priced above the average of all books, which no method name could ask for.
  • Native queries use table and column names (published_year) and the database's own SQL: here a derived table with the portable floor and concat functions grouped the books by decade. (The first version of this query grouped by the floor(...) expression directly, and H2 refused it at run time with Column "PUBLISHED_YEAR" must be in the GROUP BY list - a reminder that native SQL is only checked when it runs, and that each database has its own rules.) A native query returning a whole entity (select *) gives entities; one returning other columns gives Object[] rows, or - better - a projection (covered in Spring Data JPA Projections and DTOsClick to read tutorial: Spring Data JPA Projections and DTOs).
  • Bulk update and delete with @Modifying run as single statements and return the number of rows affected. Martin's three books were repriced by 10% in one update, and the two books published before 2006 were deleted in one delete.

The SQL generated for the JPQL

Hibernate translated each JPQL query to SQL. Selected statements, with spring.jpa.show-sql=true - the aggregation, the paged query with its automatic count query, and the two bulk statements -  OUTPUT - selected SQL statements generated from the JPQL
Hibernate: select b1_0.author,count(b1_0.id),avg(b1_0.price) from book b1_0 group by b1_0.author having count(b1_0.id)>? order by count(b1_0.id) desc,b1_0.author
Hibernate: select b1_0.id,b1_0.author,b1_0.price,b1_0.published_year,b1_0.title from book b1_0 where b1_0.price>=? order by b1_0.price desc,b1_0.id fetch first ? rows only
Hibernate: select count(b1_0.id) from book b1_0 where b1_0.price>=?
Hibernate: update book b1_0 set price=(b1_0.price*cast(? as numeric(38,2))) where b1_0.author=?
Hibernate: delete from book b1_0 where b1_0.published_year<?
The constructor expression became a plain select of three columns with group by and having; the page became a fetch first ? rows only query plus a separate select count(id); and the bulk statements are single update and delete statements over all matching rows, with no select beforehand - unlike the row-by-row deleteBy of the previous tutorial.

@Modifying and the persistence context

@Modifying queries go straight to the database and bypass the persistence context - the first-level cache of entities Hibernate keeps for the current session. If entities that the update changes are already loaded, they keep their old values. clearAutomatically = true, used on adjustPrices, clears the context after the update so later reads fetch fresh data, and flushAutomatically = true writes pending changes first. Without them, code that changes entities and then runs a bulk update in the same transaction can silently work with stale objects. The persistence context is explained in the next tutorials, on relationships and transactions.

JPQL is checked at startup

Spring Data hands each @Query to Hibernate when the application starts, so a mistake in JPQL fails at startup, not when the query first runs. A repository with a misspelled entity name -
A repository whose JPQL names an entity that does not exist
//BrokenQueryRepository.java
package com.javabrahman.data.brokenquery;

import com.javabrahman.data.query.Book;
import java.util.List;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;

public interface BrokenQueryRepository extends JpaRepository<Book, Long> {
  @Query("select b from Boook b where b.author = ?1")   // "Boook": a typo
  List<Book> byAuthor(String author);
}
BrokenQueryApp.java - starts with the broken repository and prints the cause
//BrokenQueryApp.java
package com.javabrahman.data.brokenquery;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.persistence.autoconfigure.EntityScan;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;

@SpringBootApplication
@EntityScan("com.javabrahman.data.query")
@EnableJpaRepositories(basePackageClasses = BrokenQueryRepository.class)
public class BrokenQueryApp {
  public static void main(String[] args) {
    try (var context = SpringApplication.run(BrokenQueryApp.class, args)) {
      System.out.println("Started");
    } catch (Exception e) {
      Throwable cause = e;
      while (cause.getCause() != null) {
        cause = cause.getCause();
      }
      System.out.println("Application failed to start. Root cause: " + cause.getClass().getSimpleName() + "\n  "
          + cause.getMessage().lines().findFirst().orElse(""));
    }
  }
}
 OUTPUT of the above code
Application failed to start. Root cause: UnknownEntityException
  Could not resolve root entity 'Boook'
The startup message names the unknown entity, and the method it belongs to is part of the longer exception chain. A mistake inside a native query, on the other hand, is only found when the query runs, because Hibernate cannot check database-specific SQL - a good reason to prefer JPQL where it is enough.

JPQL or native SQL?

  • Prefer JPQL: it is portable between databases, validated at startup, refers to the names in your Java code - so a renamed field shows up as a startup failure - and returns entities that Hibernate manages.
  • Use native SQL for features JPQL does not have: window functions, recursive queries, database-specific functions, JSON operators, full-text search, optimiser hints, and reports that are easier to tune in plain SQL. Accept that it ties the query to a database and is checked only when it runs.
  • Keep queries short and named for their purpose (booksPerDecade, not query7), and cover each with a test that runs against a real database of the same kind as production (testing is covered in module 6).
  • Never build a query by joining strings with user input. Always use parameters, as every example here does: they are bound separately, so the input can never become part of the SQL.

Choosing between the three kinds of query

  • Derived query methods for simple lookups by one or two properties: no query to write, and checked at startup.
  • @Query with JPQL when the condition is more complex, needs a join, grouping, a subquery, or just deserves a readable method name.
  • @Query with native SQL when only the database's own SQL can do it.
  • Specifications or Querydsl when the set of conditions is decided at runtime, for example a search form with many optional filters.

Summary

In this tutorial we first saw why derived queries are sometimes not enough, and what JPQL is: SQL-like queries on entities and fields. We wrote JPQL queries with named parameters, a case-insensitive text search with a Sort parameter, aggregation into a record with a constructor expression, paging with Pageable and an automatic count query, and a subquery. We then wrote native SQL queries, ran bulk update and delete statements with @Modifying, and learned why clearAutomatically matters for the persistence context. Next we saw that JPQL is checked at startup, and compared JPQL with native SQL and with the other kinds of query. The next tutorial connects entities to each other: JPA entity relationshipsClick to read tutorial: JPA Entity Relationships: OneToOne, OneToMany, ManyToMany.