Spring Data JPA Overview
Spring Data JPA is part of the larger Spring Data family. It makes it much easier to work with relational databases in Spring applications by cutting out most of the repetitive code needed to save, read, update and delete data.
This guide explains how Spring Data JPA relates to JPA and Hibernate, then builds a complete example step by step: configuration, entity, repository, derived and custom queries, pagination, projections, transactions, auditing, a REST controller and repository tests, with formatted Java code throughout.
1. What Is Spring Data JPA?
Without Spring Data JPA, developers usually write a DAO (Data Access Object) class for every entity and fill it with the same kind of code again and again: opening an EntityManager, writing queries, handling transactions and mapping results. Spring Data JPA removes this boilerplate. You simply create an interface, and Spring builds the implementation for you at runtime.
@Repository
public class EmployeeDao {
@PersistenceContext
private EntityManager entityManager;
public Employee findById(Long id) {
return entityManager.find(Employee.class, id);
}
public List<Employee> findByDepartment(String department) {
return entityManager
.createQuery("SELECT e FROM Employee e WHERE e.department = :dept", Employee.class)
.setParameter("dept", department)
.getResultList();
}
@Transactional
public Employee save(Employee employee) {
entityManager.persist(employee);
return employee;
}
}
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
List<Employee> findByDepartment(String department);
}
The interface gives you findById(), save() and many other methods for free, and findByDepartment() is turned into a query from its name.
2. How It Fits with JPA and Hibernate
It helps to understand the three layers involved:
Your code (services, controllers)
|
v
Spring Data JPA repositories, derived queries, paging, auditing
|
v
JPA (Jakarta Persistence API) the standard: @Entity, EntityManager, JPQL
|
v
Hibernate the implementation that generates SQL
|
v
JDBC driver -> Database (MySQL, PostgreSQL, H2 ...)
JPA is a specification: a set of rules and interfaces for mapping Java objects to database tables. Hibernate is the most popular implementation of JPA and does the actual work of talking to the database. Spring Data JPA sits on top of both and adds a simpler, repository-based way of using them.
In short, Spring Data JPA does not replace JPA or Hibernate. It uses them underneath and makes them easier to work with.
3. Key Features
| Feature | What it means |
|---|---|
| Ready-made repositories | Common CRUD methods such as save(), findById(), findAll() and deleteById() with no code |
| Derived queries | Queries created from method names, such as findByEmail(String email) |
| Custom queries | Your own JPQL or native SQL with the @Query annotation |
| Pagination and sorting | Built-in Pageable, Page and Sort support |
| Projections | Load only selected columns into interfaces or records |
| Auditing | Automatically track who created or changed a record and when |
| Transactions | Repository methods are transactional by default |
4. Repository Interfaces
Spring Data JPA offers a family of repository interfaces, each adding more features:
| Interface | What it provides |
|---|---|
Repository | A marker interface with no methods; Spring still detects it |
CrudRepository | Basic create, read, update and delete operations; collection results are Iterable |
ListCrudRepository | The same CRUD methods, but returning List instead of Iterable |
PagingAndSortingRepository | findAll(Pageable) and findAll(Sort); since Spring Data 3 it no longer includes CRUD methods by itself |
ListPagingAndSortingRepository | Sorting methods that return List |
JpaRepository | All of the above plus JPA-specific methods such as flush(), saveAndFlush(), getReferenceById() and batch deletes |
In most projects, developers extend JpaRepository because it includes everything they usually need. If you want to expose fewer operations, for example a read-only repository, extend Repository and declare only the methods you want.
5. Setting Up the Project
Add the dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- Database driver: choose the one for your database -->
<dependency>
<groupId>com.mysql</groupId>
<artifactId>mysql-connector-j</artifactId>
<scope>runtime</scope>
</dependency>
<!-- In-memory database for tests -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>test</scope>
</dependency>
Configure the database
spring:
datasource:
url: jdbc:mysql://localhost:3306/company
username: app_user
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate # check tables match entities; use a migration tool to change them
show-sql: true # print SQL while learning; turn off in production
open-in-view: false # keep database access inside the service layer
properties:
hibernate:
format_sql: true
Spring Boot creates the data source, the EntityManager and the transaction manager from these settings. For schema changes, use a migration tool such as Flyway or Liquibase instead of ddl-auto=update; see Spring Boot Database Migration.
6. Creating an Entity
An entity is a Java class mapped to a database table. Each object is one row:
package com.example.employees;
import java.math.BigDecimal;
import jakarta.persistence.Column;
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;
@Column(nullable = false, length = 100)
private String name;
@Column(nullable = false, unique = true)
private String email;
private String department;
private BigDecimal salary;
protected Employee() {
// required by JPA
}
public Employee(String name, String email, String department, BigDecimal salary) {
this.name = name;
this.email = email;
this.department = department;
this.salary = salary;
}
public Long getId() { return id; }
public String getName() { return name; }
public String getEmail() { return email; }
public String getDepartment() { return department; }
public BigDecimal getSalary() { return salary; }
public void setDepartment(String department) { this.department = department; }
public void setSalary(BigDecimal salary) { this.salary = salary; }
}
| Annotation | Purpose |
|---|---|
@Entity | Marks the class as a JPA entity |
@Table | Sets the table name (default: the class name) |
@Id | Marks the primary key |
@GeneratedValue | Lets the database generate the key; IDENTITY uses an auto-increment column |
@Column | Customizes a column, such as length, nullability or uniqueness |
JPA needs a no-argument constructor; making it protected stops other code from creating empty employees by accident.
7. Creating a Repository Interface
package com.example.employees;
import java.util.List;
import java.util.Optional;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
Optional<Employee> findByEmail(String email);
List<Employee> findByDepartment(String department);
@Query("SELECT e FROM Employee e WHERE e.name LIKE %:keyword%")
List<Employee> searchByName(@Param("keyword") String keyword);
}
The two type parameters are the entity type (Employee) and the type of its ID (Long). There is no implementation class to write: Spring creates one automatically when the application starts, and you can inject it anywhere.
8. Built-in CRUD Methods
| Method | What it does |
|---|---|
save(entity) | Inserts a new entity or updates an existing one, and returns the saved entity |
saveAll(entities) | Saves several entities at once |
findById(id) | Returns an Optional that is empty if no row exists |
findAll() | Returns all rows (avoid on large tables; use paging instead) |
existsById(id) | Checks whether a row exists |
count() | Counts all rows |
deleteById(id) / delete(entity) | Deletes a row |
getReferenceById(id) | Returns a lazy reference without loading the row immediately |
Employee asha = employeeRepository.save(
new Employee("Asha Verma", "asha@example.com", "IT", new BigDecimal("75000")));
Optional<Employee> found = employeeRepository.findById(asha.getId());
found.ifPresent(e -> System.out.println("Found " + e.getName()));
long total = employeeRepository.count();
employeeRepository.deleteById(asha.getId());
9. Derived Query Methods
Spring Data reads the method name, splits it into parts and builds a JPQL query. The part after findBy names entity properties, joined with keywords:
| Keyword | Example method | Generated condition |
|---|---|---|
And / Or | findByDepartmentAndSalaryGreaterThan | department = ? AND salary > ? |
Between | findBySalaryBetween | salary BETWEEN ? AND ? |
LessThan / GreaterThanEqual | findBySalaryLessThan | salary < ? |
Containing + IgnoreCase | findByNameContainingIgnoreCase | UPPER(name) LIKE UPPER('%?%') |
StartingWith | findByEmailStartingWith | email LIKE '?%' |
In | findByDepartmentIn | department IN (?) |
IsNull | findByDepartmentIsNull | department IS NULL |
OrderBy | findByDepartmentOrderByNameAsc | ... ORDER BY name ASC |
Top / First | findTop3ByOrderBySalaryDesc | First 3 rows by highest salary |
countBy / existsBy | countByDepartment, existsByEmail | Returns a number or a boolean |
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
List<Employee> findByDepartmentAndSalaryGreaterThan(String department, BigDecimal salary);
List<Employee> findByNameContainingIgnoreCase(String text);
List<Employee> findByDepartmentIn(Collection<String> departments);
List<Employee> findTop3ByOrderBySalaryDesc();
long countByDepartment(String department);
boolean existsByEmail(String email);
}
Spring checks these methods at startup. If a name refers to a property that does not exist, the application fails to start with a clear error, so mistakes are found early. Keep derived names short; for complex conditions, use @Query.
10. Custom Queries with @Query
When a method name would become too long, write the query yourself in JPQL, which uses entity and field names instead of table and column names:
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
// JPQL with named parameters
@Query("SELECT e FROM Employee e WHERE e.department = :dept AND e.salary >= :minSalary")
List<Employee> findWellPaid(@Param("dept") String department,
@Param("minSalary") BigDecimal minSalary);
// Native SQL when you need database-specific features
@Query(value = "SELECT * FROM employees WHERE email LIKE %:domain", nativeQuery = true)
List<Employee> findByEmailDomain(@Param("domain") String domain);
// Bulk update: needs @Modifying and a transaction
@Modifying
@Transactional
@Query("UPDATE Employee e SET e.salary = e.salary * :factor WHERE e.department = :dept")
int raiseSalaries(@Param("dept") String department, @Param("factor") BigDecimal factor);
}
| Option | Use when |
|---|---|
| Derived query | Simple conditions that read clearly as a method name |
@Query with JPQL | Joins, several conditions or calculated results, while staying database-independent |
@Query with nativeQuery = true | Database-specific SQL features |
@Modifying | UPDATE or DELETE statements that change many rows at once |
Bulk updates go straight to the database and skip the entities already loaded in memory. Add @Modifying(clearAutomatically = true) if the same transaction reads those entities again afterwards.
11. Pagination and Sorting
Never load a whole large table at once. Use Pageable to read one page at a time, and Sort to order the results:
public interface EmployeeRepository extends JpaRepository<Employee, Long> {
Page<Employee> findByDepartment(String department, Pageable pageable);
}
Pageable firstPage = PageRequest.of(0, 10, Sort.by("name").ascending()); // page 0, 10 rows
Page<Employee> page = employeeRepository.findByDepartment("IT", firstPage);
System.out.println("Employees on this page: " + page.getNumberOfElements());
System.out.println("Total employees: " + page.getTotalElements());
System.out.println("Total pages: " + page.getTotalPages());
A Page runs an extra count query to know the total. If you only need “is there a next page?”, return Slice instead, which skips the count. See Spring Boot Pagination and Sorting for REST examples.
12. Projections: Reading Only What You Need
Often you only need a few columns, for example names and emails for a list screen. A projection loads just those fields, which is faster than loading full entities:
package com.example.employees;
public interface EmployeeSummary {
String getName();
String getEmail();
}
// Text between "find" and "By" is ignored, so this is a normal derived query
List<EmployeeSummary> findSummaryByDepartment(String department);
Spring Data selects only the name and email columns and returns lightweight objects. You can also use a Java record with matching component names as the return type. Projections are a good fit for API responses, because they avoid exposing whole entities.
13. Transactions
Repository methods already run in transactions: read methods use read-only transactions and save or delete methods use read-write ones. When one business operation calls the repository several times, put @Transactional on the service method so all steps succeed or fail together:
@Service
public class EmployeeTransferService {
private final EmployeeRepository employeeRepository;
public EmployeeTransferService(EmployeeRepository employeeRepository) {
this.employeeRepository = employeeRepository;
}
@Transactional
public void transfer(Long employeeId, String newDepartment, BigDecimal newSalary) {
Employee employee = employeeRepository.findById(employeeId)
.orElseThrow(() -> new IllegalArgumentException("Employee not found: " + employeeId));
employee.setDepartment(newDepartment);
employee.setSalary(newSalary);
// no save() needed: changes to a loaded entity are written at commit
}
@Transactional(readOnly = true)
public List<Employee> listDepartment(String department) {
return employeeRepository.findByDepartment(department);
}
}
Inside a transaction, Hibernate tracks the entities it loaded and writes any changes automatically when the transaction commits. This is called dirty checking. See Spring Boot Transaction Management for more.
14. Auditing: Created and Updated Timestamps
Spring Data JPA can fill in creation and update timestamps automatically. Enable auditing once:
package com.example.config;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
@Configuration
@EnableJpaAuditing
public class JpaAuditingConfig {
}
@Entity
@Table(name = "employees")
@EntityListeners(AuditingEntityListener.class)
public class Employee {
// ... id, name, email, department, salary
@CreatedDate
@Column(nullable = false, updatable = false)
private Instant createdAt;
@LastModifiedDate
private Instant updatedAt;
}
Now every insert sets createdAt, and every update sets updatedAt. To record who made the change, add @CreatedBy and @LastModifiedBy fields and an AuditorAware bean that returns the current user.
15. Using the Repository in a Service and REST Controller
package com.example.employees;
import java.util.List;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Service
public class EmployeeService {
private final EmployeeRepository employeeRepository;
public EmployeeService(EmployeeRepository employeeRepository) {
this.employeeRepository = employeeRepository;
}
@Transactional
public Employee saveEmployee(Employee employee) {
if (employeeRepository.existsByEmail(employee.getEmail())) {
throw new IllegalStateException("Email already in use: " + employee.getEmail());
}
return employeeRepository.save(employee);
}
@Transactional(readOnly = true)
public Employee getEmployee(Long id) {
return employeeRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Employee not found: " + id));
}
@Transactional(readOnly = true)
public List<EmployeeSummary> getByDepartment(String department) {
return employeeRepository.findSummaryByDepartment(department);
}
}
package com.example.employees;
import java.util.List;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
@RestController
@RequestMapping("/api/employees")
public class EmployeeController {
private final EmployeeService employeeService;
public EmployeeController(EmployeeService employeeService) {
this.employeeService = employeeService;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public Employee create(@RequestBody Employee employee) {
return employeeService.saveEmployee(employee);
}
@GetMapping("/{id}")
public Employee get(@PathVariable Long id) {
return employeeService.getEmployee(id);
}
@GetMapping
public List<EmployeeSummary> byDepartment(@RequestParam String department) {
return employeeService.getByDepartment(department);
}
}
The controller handles HTTP, the service holds business rules and transactions, and the repository talks to the database. For real APIs, accept and return separate request and response classes (DTOs) instead of the entity, and add validation; see Spring Boot REST API.
16. Testing Repositories with @DataJpaTest
@DataJpaTest starts only the JPA part of the application with an in-memory database, so repository tests are fast. Each test runs in a transaction that is rolled back afterwards:
package com.example.employees;
import static org.assertj.core.api.Assertions.assertThat;
import java.math.BigDecimal;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
@DataJpaTest
class EmployeeRepositoryTest {
@Autowired
private EmployeeRepository employeeRepository;
@Test
void findsEmployeeByEmail() {
employeeRepository.save(
new Employee("Asha Verma", "asha@example.com", "IT", new BigDecimal("75000")));
assertThat(employeeRepository.findByEmail("asha@example.com"))
.isPresent()
.get()
.extracting(Employee::getName)
.isEqualTo("Asha Verma");
}
@Test
void countsEmployeesPerDepartment() {
employeeRepository.save(new Employee("Ravi", "ravi@example.com", "IT", new BigDecimal("60000")));
employeeRepository.save(new Employee("Meera", "meera@example.com", "HR", new BigDecimal("55000")));
assertThat(employeeRepository.countByDepartment("IT")).isEqualTo(1);
}
}
This import is for Spring Boot 3; Spring Boot 4 moved the test annotation to a separate module. To test against the same database you use in production, combine @DataJpaTest with Testcontainers. See Spring Boot Testing.
17. Common Mistakes and Best Practices
| Mistake | Problem | Better approach |
|---|---|---|
| Loading related data in a loop | The N+1 query problem: one query for the list plus one per row | Fetch what you need with a join fetch, @EntityGraph or a projection |
ddl-auto=update in production | Unpredictable schema changes and no history | validate plus Flyway or Liquibase migrations |
| Returning entities from REST APIs | Exposes internal fields and can trigger lazy-loading errors | Return DTOs or projections |
findAll() on large tables | Loads everything into memory | Use Pageable or a filtered query |
FetchType.EAGER everywhere | Loads far more data than needed | Keep associations lazy and fetch on demand |
Leaving open-in-view on | Database queries can run while rendering the response | Set spring.jpa.open-in-view=false |
| Very long derived method names | Hard to read and maintain | Switch to @Query |
- Keep repositories small and focused on one entity (or aggregate).
- Put
@Transactionalon service methods that change data in several steps. - Turn on
show-sqlwhile developing to see the SQL that Hibernate generates. - Test custom queries with
@DataJpaTest.
18. Why Use Spring Data JPA?
The biggest benefit is less code. You focus on your business logic instead of writing the same database code over and over. Your data layer becomes cleaner and more consistent across the whole project, and features like pagination, sorting, projections and auditing come built in. Because it works on top of JPA, you can still drop down to custom queries or the EntityManager whenever you need more control.
19. Conclusion
Spring Data JPA is the standard way to handle database access in modern Spring Boot applications. By combining the power of JPA and Hibernate with simple repository interfaces, it lets you build a complete data layer with very little code: entities map your tables, repositories provide CRUD and derived queries, @Query covers custom needs, and paging, projections, transactions and auditing are built in. This makes your applications faster to develop and easier to maintain.
20. Frequently Asked Questions (FAQ)
Q1. How does Spring create a repository implementation when I only write an interface?
At startup, Spring Data scans for interfaces that extend Repository and builds a dynamic proxy for each one. Calls to built-in methods like save() or findAll() are forwarded to SimpleJpaRepository, the default implementation. Calls to your own methods are turned into JPQL queries based on the method name or the @Query annotation.
This is why you can inject EmployeeRepository into a service even though no class in your code implements it.
Q2. What happens if I misspell a property in a derived query method name?
The application fails to start. Spring Data parses every derived method name at startup and checks each property against the entity. A method like findByDepartmnt(String d) produces an error similar to:
No property 'departmnt' found for type 'Employee'
This is useful: typos are caught immediately instead of surfacing as a runtime bug in production.
Q3. How does save() decide whether to INSERT or UPDATE?
save() asks whether the entity is new:
- If the entity has a
@Versionfield of a wrapper type, it is new when the version isnull. - Otherwise, it is new when the
@Idisnull(or0for primitive IDs).
New entities are passed to EntityManager.persist() (INSERT). Existing ones go to EntityManager.merge(), which first SELECTs the row and then UPDATEs it. If you assign IDs yourself, such as UUIDs, implement Persistable so Spring does not treat every new entity as an existing one.
Q4. What is the difference between findById() and getReferenceById()?
findById() | getReferenceById() | |
|---|---|---|
| Hits the database | Immediately | Only when a field other than the ID is accessed |
| Return type | Optional<T> | A lazy proxy of T |
| Missing row | Empty Optional | EntityNotFoundException on first access |
Use getReferenceById() when you only need to link entities, for example setting order.setCustomer(customerRepository.getReferenceById(id)), because it avoids an unnecessary SELECT.
Q5. Are repository methods transactional by default?
Partly. Methods inherited from SimpleJpaRepository already carry @Transactional: read methods run with readOnly = true, and write methods like save() and delete() run in a normal read-write transaction.
Query methods you declare in your interface do not get a transaction automatically. Reads usually still work, but update and delete queries need @Transactional on the method or on the calling service. When a service method calls several repository methods, put @Transactional on the service method so all of them share one transaction.
Q6. How do I write a bulk UPDATE or DELETE with @Query?
Add @Modifying so Spring runs it with executeUpdate() instead of expecting a result set, and make sure it runs inside a transaction:
@Transactional
@Modifying(clearAutomatically = true)
@Query("UPDATE Employee e SET e.department = :newDept WHERE e.department = :oldDept")
int moveDepartment(@Param("oldDept") String oldDept, @Param("newDept") String newDept);
Bulk queries go straight to the database and bypass the persistence context, so already-loaded entities can be stale. clearAutomatically = true clears the context after the update to avoid reading outdated values.
Q7. How can I load only a few columns instead of the whole entity?
Use a projection. Declare an interface or a record with only the fields you need and use it as the return type:
public record EmployeeSummary(String name, String email) {}
List<EmployeeSummary> findByDepartment(String department);
Spring Data selects only the name and email columns. Projections are ideal for list screens and API responses where loading full entities, and their relationships, would waste memory and time.
Q8. What is the N+1 query problem and how do I fix it?
It happens when you load a list of N entities and then access a lazy relationship on each one. Hibernate runs 1 query for the list plus N extra queries, one per entity. With 500 departments, that is 501 queries.
Fix it by fetching the relationship in the same query, either with JOIN FETCH or with @EntityGraph:
@EntityGraph(attributePaths = "employees")
List<Department> findAll();
Turn on spring.jpa.show-sql=true during development to spot repeated queries early.
Q9. Should I return Page or Slice for paginated results?
Pageruns an extraCOUNTquery, so it knows the total number of elements and pages. Use it when the UI shows page numbers like “Page 3 of 42”.Sliceskips the count. It fetches one extra row to tell whether a next page exists. Use it for “Load more” buttons and infinite scrolling.
On large tables the count query can be expensive, so Slice is often much faster.
Q10. Can I run native SQL instead of JPQL?
Yes. Set nativeQuery = true and write SQL against table and column names:
@Query(value = "SELECT * FROM employees WHERE email LIKE %:domain",
nativeQuery = true)
List<Employee> findByEmailDomain(@Param("domain") String domain);
Native queries let you use database-specific features such as window functions or JSON operators. The trade-off is portability: the query is tied to one database and is not checked against your entities at startup, so prefer JPQL unless you really need SQL.
Q11. How do I prevent lost updates when two users edit the same row?
Use optimistic locking. Add a version field to the entity:
@Version
private Long version;
Hibernate adds WHERE version = ? to every UPDATE and increments the value. If another transaction changed the row first, no row matches and Spring throws ObjectOptimisticLockingFailureException. Catch it to return HTTP 409 Conflict, or retry the whole transaction (for example with Spring Retry) when the operation is safe to repeat.
Q12. When should I use pessimistic locking, and how do I avoid waiting forever?
Use it when conflicts are frequent and retrying is expensive, such as decrementing stock or allocating seats. It issues SELECT ... FOR UPDATE, so other transactions block until yours commits:
@Lock(LockModeType.PESSIMISTIC_WRITE)
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
@Query("SELECT p FROM Product p WHERE p.id = :id")
Optional<Product> findByIdForUpdate(@Param("id") Long id);
The lock timeout hint, in milliseconds, makes a blocked call fail with an exception instead of hanging. Keep these transactions short, and always lock rows in the same order to avoid deadlocks. Support for the timeout varies by database.
Q13. Should I disable spring.jpa.open-in-view?
In most production services, yes. It is enabled by default, which is why Spring Boot logs a warning at startup. It keeps the persistence context open for the whole web request, so lazy relationships can load while JSON is being written or a view is rendered.
The cost is that a database connection can stay held for the entire request, and lazy loading quietly fires extra queries outside your service layer. Set spring.jpa.open-in-view=false, then load what each use case needs explicitly with fetch joins, entity graphs or DTO projections. A LazyInitializationException after the change points to exactly where the data was being loaded implicitly.
Q14. Why does saveAll() not batch my inserts?
Two common causes:
- Batching is off. Hibernate sends one statement per row unless you set a batch size.
- The ID uses
GenerationType.IDENTITY. Hibernate must run each INSERT immediately to read the generated key, so it cannot batch inserts at all.
Switch to a sequence with a matching allocation size, then enable batching:
spring.jpa.properties.hibernate.jdbc.batch_size=50
spring.jpa.properties.hibernate.order_inserts=true
spring.jpa.properties.hibernate.order_updates=true
For very large imports, also flush() and clear() the EntityManager every batch so the persistence context does not grow without limit.
Q15. Why is pagination slow or wrong when I JOIN FETCH a collection?
When a query fetch-joins a collection, each parent appears in several rows, so the database cannot apply LIMIT correctly. Hibernate therefore loads every matching row and paginates in memory, logging warning HHH90003004. On a large table this can exhaust memory.
Use two queries instead: page the parent IDs, then fetch those parents with their collections:
@Query("SELECT d.id FROM Department d")
Page<Long> findIds(Pageable pageable);
@EntityGraph(attributePaths = "employees")
List<Department> findByIdIn(List<Long> ids);
Setting hibernate.query.fail_on_pagination_over_collection_fetch=true turns the warning into an error so the problem cannot reach production unnoticed.
Q16. How do I build dynamic search queries with optional filters?
Avoid writing one method per filter combination. The main options are:
| Approach | Best for |
|---|---|
| Specifications | Type-safe, composable predicates on top of the Criteria API, with no extra libraries |
| Querydsl | Fluent, readable queries generated from your entities, at the cost of a build-time step |
| Query by Example | Simple equality and string matching built from a probe object |
With Specifications, extend JpaSpecificationExecutor<Employee> and combine only the filters the user supplied:
Specification<Employee> spec = Specification.where(null);
if (dept != null) {
spec = spec.and((root, q, cb) -> cb.equal(root.get("department"), dept));
}
if (name != null) {
spec = spec.and((root, q, cb) -> cb.like(root.get("name"), "%" + name + "%"));
}
return employeeRepository.findAll(spec, pageable);
Q17. How do I add custom code to a Spring Data repository?
Use a repository fragment. Declare an interface, implement it in a class whose name ends with Impl, and extend the fragment from your repository:
public interface EmployeeRepositoryCustom {
List<Employee> findWithComplexRules(SearchCriteria criteria);
}
class EmployeeRepositoryCustomImpl implements EmployeeRepositoryCustom {
@PersistenceContext
private EntityManager em;
public List<Employee> findWithComplexRules(SearchCriteria criteria) {
// Criteria API, native SQL, JDBC, anything you need
}
}
public interface EmployeeRepository
extends JpaRepository<Employee, Long>, EmployeeRepositoryCustom { }
Spring detects the Impl class and merges it into the generated proxy, so callers still use one repository.
Q18. How do I process millions of rows without running out of memory?
Do not call findAll() on a huge table. Two good options:
- Stream the results. Return
Stream<T>, call it inside a read-only transaction, and close it with try-with-resources. Detach each processed entity, or clear theEntityManagerperiodically, so it can be garbage-collected. - Use keyset scrolling. Since Spring Data 3.1, a method returning
Window<T>that takes aScrollPositioncontinues from the last seen key instead of usingOFFSET, so every page is equally fast.
// In EmployeeRepository
Stream<Employee> streamAllBy();
// In the service
@Transactional(readOnly = true)
public void exportAll() {
try (Stream<Employee> employees = employeeRepository.streamAllBy()) {
employees.forEach(e -> {
writer.write(e);
entityManager.detach(e);
});
}
}
Q19. How do I record who created or changed an entity, and when?
Use Spring Data JPA auditing. Enable it, add the listener, and annotate the fields:
@EnableJpaAuditing
@Configuration
class AuditConfig {
@Bean
AuditorAware<String> auditorAware() {
return () -> Optional.ofNullable(SecurityContextHolder.getContext().getAuthentication())
.map(Authentication::getName);
}
}
@EntityListeners(AuditingEntityListener.class)
@MappedSuperclass
public abstract class Auditable {
@CreatedDate private Instant createdAt;
@LastModifiedDate private Instant updatedAt;
@CreatedBy private String createdBy;
@LastModifiedBy private String updatedBy;
}
Extend Auditable from your entities and the fields are filled in automatically on insert and update. Note that bulk @Modifying queries skip entity listeners, so they do not update these fields.
Q20. Does @Transactional(readOnly = true) actually improve performance?
Yes, with Hibernate it does more than document intent:
- Hibernate sets the flush mode to
MANUAL, so it never flushes or runs dirty checking at commit. - Loaded entities are marked read-only, so Hibernate does not keep snapshot copies for comparison, which cuts memory use on large reads.
- The JDBC connection is flagged read-only, which some drivers and databases optimize.
- A routing
DataSourcecan use the flag to send queries to a read replica.
Put it on query-only service methods. Any changes made to entities inside such a transaction are silently not saved, so never use it on methods that write.