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, auditing, a REST controller and repository tests, with formatted Java code throughout.
1. What Is Spring Data JPA?
Spring Data JPA is a framework within the Spring ecosystem that provides a repository abstraction on top of the Java Persistence API (JPA). It simplifies database operations by reducing the amount of boilerplate data-access code. It provides repository interfaces for common CRUD (Create, Read, Update, Delete) operations, supports custom queries, and integrates with JPA providers such as Hibernate.
Instead of implementing repetitive DAO methods manually, developers can define repository interfaces, and Spring Data JPA automatically generates their implementations at runtime.
To understand how Spring Data JPA simplifies database access, let's compare the
traditional approach of writing a DAO class using EntityManager
with the repository-based approach. This comparison highlights how
Spring Data JPA reduces boilerplate code and improves maintainability.
@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
Spring Data JPA, JPA, and Hibernate work together, but each has a different responsibility. Spring Data JPA simplifies data access, JPA defines the standard, and Hibernate provides the ORM implementation. Let's look at how these three layers fit together.
Important Points
- Spring Data JPA: Simplifies database access through repository interfaces and query methods.
- JPA (Jakarta Persistence): Defines the standard APIs and annotations for object-relational mapping.
- Hibernate: Implements JPA and handles ORM operations, translating entity operations into SQL.
- JDBC Driver: Enables Java applications to communicate with the database.
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 |
| Auditing | Automatically track who created or changed a record and when |
4. 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.
5. Creating an Entity
An entity is a Java class mapped to a database table. Each object is one row:
package com.javabytechie.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.
6. Creating a Repository Interface
package com.javabytechie.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.
7. 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());
8. 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.
9. 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.
10. 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.
11. Using the Repository in a Service and REST Controller
package com.javabytechie.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<Employee> getByDepartment(String department) {
return employeeRepository.findByDepartment(department);
}
}
package com.javabytechie.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<Employee> 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.
12. 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.javabytechie.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.
13. 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.
14. 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.
15. 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.
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. 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.
Q6. 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.
Q7. 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.
Q8. 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.
Q9. 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.
Q10. 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.
Q11. 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.
Q12. 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);
Q13. 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.
Q14. 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);
});
}
}
Q15. 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.