Spring Boot JPA and Hibernate
Most Spring Boot applications store their data in a relational database such as MySQL or PostgreSQL. JPA and Hibernate let you work with that data as ordinary Java objects instead of writing SQL by hand for every operation.
This guide explains how Spring Boot sets up JPA and Hibernate, and how to map entities, IDs, field types and relationships, choose fetch types and cascades, understand the persistence context, write JPQL, avoid the N+1 query problem, use optimistic locking, and tune and debug the generated SQL, with formatted Java and configuration examples. For repository interfaces, see Spring Data JPA Overview.
1. What Are JPA and Hibernate?
| Term | What it is | Example |
|---|---|---|
| ORM (Object-Relational Mapping) | The technique of mapping Java classes to database tables and objects to rows | A Product class mapped to a products table |
| JPA (Jakarta Persistence API) | The standard Java specification for ORM: annotations, interfaces and a query language | @Entity, EntityManager, JPQL |
| Hibernate | The most popular implementation of JPA; it generates and runs the SQL | Hibernate ORM 6 in Spring Boot 3 |
| Spring Data JPA | A Spring layer on top of JPA that generates repositories | JpaRepository |
Think of JPA as the rules and Hibernate as the engine that follows them. Your code uses JPA annotations and interfaces, and Hibernate does the work underneath. Because your code depends on the standard, you rarely need Hibernate-specific classes.
2. How Spring Boot Configures JPA and Hibernate
When you add spring-boot-starter-data-jpa, Spring Boot’s auto-configuration creates everything JPA needs:
application.yml (url, username, password)
|
v
DataSource connection pool (HikariCP)
|
v
EntityManagerFactory Hibernate, configured with your entities
|
v
JpaTransactionManager makes @Transactional work
|
v
Repositories and EntityManager injected into your beans
- It scans the package of your main application class (and sub-packages) for
@Entityclasses. - Hibernate detects the database type and chooses the right SQL dialect automatically.
- Every setting can be changed with
spring.jpa.*andspring.datasource.*properties.
3. Setting Up the Project
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.postgresql</groupId>
<artifactId>postgresql</artifactId>
<scope>runtime</scope>
</dependency>
spring:
datasource:
url: jdbc:postgresql://localhost:5432/shop
username: shop_user
password: ${DB_PASSWORD}
jpa:
hibernate:
ddl-auto: validate # see section 15
open-in-view: false
properties:
hibernate:
format_sql: true
jdbc:
batch_size: 50 # see section 17
For quick experiments you can use the H2 in-memory database instead: add the com.h2database:h2 dependency and leave out the spring.datasource settings, and Spring Boot configures it automatically.
4. Mapping Your First Entity
package com.example.shop.product;
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 = "products")
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, length = 120)
private String name;
@Column(nullable = false, precision = 10, scale = 2)
private BigDecimal price;
private int stock;
protected Product() {
// required by JPA
}
public Product(String name, BigDecimal price, int stock) {
this.name = name;
this.price = price;
this.stock = stock;
}
public Long getId() { return id; }
public String getName() { return name; }
public BigDecimal getPrice() { return price; }
public int getStock() { return stock; }
public void changePrice(BigDecimal newPrice) { this.price = newPrice; }
public void reduceStock(int quantity) { this.stock -= quantity; }
}
CREATE TABLE products (
id BIGSERIAL PRIMARY KEY,
name VARCHAR(120) NOT NULL,
price NUMERIC(10, 2) NOT NULL,
stock INTEGER NOT NULL
);
| Annotation | Purpose |
|---|---|
@Entity | Marks the class as a persistent entity |
@Table | Sets the table name; without it, the class name is used |
@Id | Marks the primary key |
@GeneratedValue | Tells Hibernate how the key is generated |
@Column | Sets column details such as name, length, nullability and precision |
Entities need a no-argument constructor (it may be protected) and must not be final, because Hibernate creates subclasses (proxies) for lazy loading. Instead of public setters for every field, expose methods that express business actions, such as changePrice().
5. Primary Key Generation Strategies
| Strategy | How it works | Notes |
|---|---|---|
IDENTITY | The database generates the key with an auto-increment column | Simple; but Hibernate cannot batch inserts, because it must insert each row to learn its ID |
SEQUENCE | Hibernate gets IDs from a database sequence before inserting | Best for PostgreSQL and Oracle; supports batch inserts |
UUID | Hibernate generates a random UUID | Unique across systems; larger keys and less index-friendly |
AUTO | Hibernate chooses a strategy for the database | Explicit choices are clearer |
// Sequence: Hibernate reserves 50 IDs per database call
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "order_seq")
@SequenceGenerator(name = "order_seq", sequenceName = "order_seq", allocationSize = 50)
private Long id;
// UUID: generated in Java, no database round trip
@Id
@GeneratedValue(strategy = GenerationType.UUID)
private UUID id;
6. Mapping Common Field Types
@Entity
@Table(name = "customers")
public class Customer {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String fullName;
@Enumerated(EnumType.STRING) // store "GOLD", not 0, 1, 2
@Column(length = 20)
private CustomerTier tier;
private LocalDate birthDate; // DATE column
private Instant registeredAt; // TIMESTAMP column
@Embedded
private Address address; // columns stored in the same table
@Transient
private int loginCountThisSession; // not stored at all
protected Customer() {
}
}
@Embeddable
public class Address {
private String street;
private String city;
private String postalCode;
protected Address() {
}
public Address(String street, String city, String postalCode) {
this.street = street;
this.city = city;
this.postalCode = postalCode;
}
}
| Java type or annotation | Database mapping |
|---|---|
String | VARCHAR (length 255 unless set) |
BigDecimal | NUMERIC; set precision and scale for money |
LocalDate, LocalDateTime, Instant | DATE and TIMESTAMP columns |
@Enumerated(EnumType.STRING) | The enum name as text; safer than the default ordinal number |
@Embedded / @Embeddable | A value object whose fields become columns of the owning table |
@Lob | Large text or binary data |
@Transient | A field that is not saved |
Avoid the default EnumType.ORDINAL: it stores 0, 1, 2, so adding or reordering enum values silently changes the meaning of existing data.
7. Entity Relationships
Relationships connect entities, just as foreign keys connect tables. In this example, a category has many products, and each product belongs to one category:
categories products
---------- --------------------------
id (PK) <------- category_id (FK)
name id (PK), name, price, stock
@Entity
@Table(name = "categories")
public class Category {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
private String name;
@OneToMany(mappedBy = "category") // the other side owns the foreign key
private List<Product> products = new ArrayList<>();
protected Category() {
}
public void addProduct(Product product) { // keep both sides in sync
products.add(product);
product.setCategory(this);
}
}
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "category_id") // foreign key column in products
private Category category;
void setCategory(Category category) {
this.category = category;
}
| Annotation | Meaning | Default fetch |
|---|---|---|
@ManyToOne | Many products belong to one category | EAGER |
@OneToMany | One category has many products | LAZY |
@OneToOne | One user has one profile | EAGER |
@ManyToMany | Products have many tags, tags have many products (join table) | LAZY |
The side with @JoinColumn is the owning side: it controls the foreign key. The side with mappedBy is the inverse side and only mirrors it. Always update both sides in memory, as addProduct() does.
8. Fetch Types: Lazy vs Eager
| Fetch type | Behaviour | Risk |
|---|---|---|
LAZY | Related data is loaded only when you first access it | LazyInitializationException if accessed after the transaction ends |
EAGER | Related data is always loaded with the entity | Loads more than needed and causes extra queries everywhere |
The recommended rule is simple: make every association lazy, including @ManyToOne and @OneToOne, and load related data explicitly in the queries that need it (section 13). If you see LazyInitializationException, the fix is to fetch the data inside the transaction, not to switch to EAGER.
9. Cascading and Orphan Removal
Cascading passes an operation from a parent entity to its children. It suits parent-child relationships where the children cannot exist alone, such as an order and its items:
@Entity
@Table(name = "purchase_orders")
public class PurchaseOrder {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@OneToMany(mappedBy = "order", cascade = CascadeType.ALL, orphanRemoval = true)
private List<OrderItem> items = new ArrayList<>();
public void addItem(Product product, int quantity) {
items.add(new OrderItem(this, product, quantity));
}
public void removeItem(OrderItem item) {
items.remove(item); // orphanRemoval deletes the row
}
}
@Entity
@Table(name = "order_items")
public class OrderItem {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "order_id")
private PurchaseOrder order;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "product_id")
private Product product;
private int quantity;
protected OrderItem() {
}
OrderItem(PurchaseOrder order, Product product, int quantity) {
this.order = order;
this.product = product;
this.quantity = quantity;
}
}
Saving the order now saves its items too, and removing an item from the list deletes it. Do not cascade from a child to a shared parent, for example from OrderItem to Product: deleting an item could then delete the product.
10. The Persistence Context and Entity States
The persistence context is Hibernate’s memory of the entities loaded in the current transaction. Every entity is in one of four states:
new Product(...) persist() / save()
TRANSIENT ---------------------------------------------> MANAGED
(not known to Hibernate) (tracked; changes saved at commit)
| |
transaction ends / | | remove() / delete()
clear() / detach() v v
DETACHED REMOVED
(has an ID but no longer tracked) (deleted at commit)
@Service
public class PricingService {
private final ProductRepository productRepository;
public PricingService(ProductRepository productRepository) {
this.productRepository = productRepository;
}
@Transactional
public void applyDiscount(Long productId, BigDecimal percent) {
Product product = productRepository.findById(productId).orElseThrow(); // MANAGED
BigDecimal factor = BigDecimal.ONE.subtract(percent.movePointLeft(2));
product.changePrice(product.getPrice().multiply(factor));
// No save() call: Hibernate notices the change and runs UPDATE at commit
}
}
The persistence context also acts as a first-level cache: loading the same entity twice in one transaction returns the same object and runs only one query.
11. Using the EntityManager Directly
Spring Data repositories cover most needs, but you can inject the JPA EntityManager for full control:
@Repository
public class ProductReportDao {
@PersistenceContext
private EntityManager entityManager;
@Transactional(readOnly = true)
public List<Product> findLowStock(int threshold) {
return entityManager
.createQuery("SELECT p FROM Product p WHERE p.stock < :threshold ORDER BY p.stock", Product.class)
.setParameter("threshold", threshold)
.setMaxResults(20)
.getResultList();
}
@Transactional
public void archive(Long productId) {
Product product = entityManager.find(Product.class, productId);
if (product != null) {
entityManager.remove(product);
}
}
}
| EntityManager method | Purpose |
|---|---|
persist(entity) | Makes a new entity managed; it is inserted at flush or commit |
find(Class, id) | Loads an entity by ID, or returns null |
merge(entity) | Copies a detached entity’s state into a managed one |
remove(entity) | Marks a managed entity for deletion |
createQuery(jpql, Class) | Creates a typed JPQL query |
flush() | Sends pending changes to the database now, without committing |
12. Writing JPQL Queries
JPQL (Jakarta Persistence Query Language) looks like SQL but works with entity names and fields, so it is the same for every database:
// Filter and sort
"SELECT p FROM Product p WHERE p.price BETWEEN :min AND :max ORDER BY p.price"
// Navigate a relationship (an implicit join)
"SELECT p FROM Product p WHERE p.category.name = :categoryName"
// Aggregate into a DTO record
"SELECT new com.example.shop.product.CategoryStats(c.name, COUNT(p), AVG(p.price)) "
+ "FROM Product p JOIN p.category c GROUP BY c.name"
// Load an order with its items in one query
"SELECT o FROM PurchaseOrder o JOIN FETCH o.items WHERE o.id = :id"
package com.example.shop.product;
public record CategoryStats(String category, Long productCount, Double averagePrice) {
}
Always use parameters such as :min instead of joining strings into the query; this prevents SQL injection and lets the database reuse the query plan.
13. Solving the N+1 Query Problem
The N+1 problem is the most common JPA performance issue. Loading a list of N entities runs 1 query, and then touching a lazy relationship on each one runs N more:
List<PurchaseOrder> orders = orderRepository.findAll(); // 1 query
for (PurchaseOrder order : orders) {
System.out.println(order.getItems().size()); // 1 query per order
}
// 100 orders = 101 queries
| Fix | How | Best for |
|---|---|---|
JOIN FETCH | SELECT o FROM PurchaseOrder o JOIN FETCH o.items | One specific query |
@EntityGraph | Annotate a repository method with the relationships to load | Spring Data repositories |
| Batch fetching | hibernate.default_batch_fetch_size: 50 loads lazy data in groups | A simple global improvement |
| Projections / DTOs | Select only the needed columns with a JPQL constructor or interface projection | Read-only screens and APIs |
public interface OrderRepository extends JpaRepository<PurchaseOrder, Long> {
@EntityGraph(attributePaths = "items")
List<PurchaseOrder> findByCustomerId(Long customerId); // orders and items in one query
}
Turn on SQL logging (section 16) while developing; seeing the same SELECT repeated many times is the clearest sign of an N+1 problem.
14. Optimistic Locking with @Version
When two users edit the same row at the same time, the last save can silently overwrite the first. Optimistic locking prevents this with a version column:
@Entity
public class Product {
// ... other fields
@Version
private Long version;
}
Hibernate adds WHERE version = ? to every UPDATE and increases the version. If someone else updated the row first, no row matches and Spring throws ObjectOptimisticLockingFailureException. Catch it and return HTTP 409 Conflict, or reload the data and retry.
@ExceptionHandler(ObjectOptimisticLockingFailureException.class)
@ResponseStatus(HttpStatus.CONFLICT)
public String onConflict() {
return "The product was changed by someone else. Please reload and try again.";
}
15. Schema Generation with ddl-auto
| spring.jpa.hibernate.ddl-auto | What Hibernate does at startup | Use for |
|---|---|---|
none | Nothing | Production with a migration tool |
validate | Checks that tables and columns match the entities; fails if not | Production and staging |
update | Adds missing tables and columns; never removes anything | Quick prototypes only |
create | Drops and recreates the schema | Throwaway local databases |
create-drop | Like create, then drops the schema on shutdown | Tests; the default for embedded databases |
For real applications, manage the schema with versioned migration scripts using Flyway or Liquibase, and set ddl-auto to validate so Hibernate checks that your entities still match. See Spring Boot Database Migration.
16. Logging and Inspecting SQL
spring:
jpa:
properties:
hibernate:
format_sql: true # readable, multi-line SQL
generate_statistics: true # query counts and timings per session
logging:
level:
org.hibernate.SQL: debug # every SQL statement
org.hibernate.orm.jdbc.bind: trace # the parameter values (Hibernate 6)
DEBUG org.hibernate.SQL :
select p1_0.id, p1_0.name, p1_0.price, p1_0.stock
from products p1_0
where p1_0.id=?
TRACE org.hibernate.orm.jdbc.bind : binding parameter (1:BIGINT) <- [42]
Prefer these logging settings over spring.jpa.show-sql=true, which prints to the console without a logger and cannot show parameter values. Turn them off, or down to info, in production.
17. Performance Tips
| Tip | How |
|---|---|
| Batch inserts and updates | hibernate.jdbc.batch_size: 50, order_inserts: true, order_updates: true, with SEQUENCE or UUID IDs |
| Read-only transactions | @Transactional(readOnly = true) skips dirty checking for queries |
| Load only what you need | Use projections or DTO queries for lists and reports |
| Avoid N+1 queries | JOIN FETCH, @EntityGraph or batch fetching |
| Paginate large results | Use Pageable instead of loading whole tables |
| Index your queries | Add database indexes for columns used in WHERE, JOIN and ORDER BY |
| Cache rarely changing data | Hibernate’s second-level cache with a provider such as JCache, for read-mostly entities |
@Transactional
public void importProducts(List<Product> products) {
for (int i = 0; i < products.size(); i++) {
entityManager.persist(products.get(i));
if (i > 0 && i % 50 == 0) { // same as hibernate.jdbc.batch_size
entityManager.flush(); // send the batch
entityManager.clear(); // free memory used by managed entities
}
}
}
18. Best Practices
| Do | Avoid |
|---|---|
Make all associations LAZY | FetchType.EAGER on relationships |
Use EnumType.STRING for enums | Ordinal enum columns |
| Keep both sides of a relationship in sync with helper methods | Updating only one side in memory |
| Cascade only from parents to children they own | Cascading to shared entities |
Use validate plus Flyway or Liquibase | ddl-auto=update in production |
Set spring.jpa.open-in-view=false | Running queries while rendering responses |
| Return DTOs from APIs | Serializing entities directly |
Add @Version to entities edited by many users | Silent lost updates |
| Watch the SQL log while developing | Discovering N+1 problems in production |
19. Summary
JPA defines how Java objects map to database tables, Hibernate implements those rules, and Spring Boot configures both automatically from a starter and a few properties. Map entities with @Entity, @Id and @Column, choose an ID strategy, connect entities with lazy relationships and careful cascading, and let the persistence context save changes through dirty checking. Use JPQL with fetch joins or entity graphs to avoid N+1 queries, @Version to prevent lost updates, migrations for schema changes, and SQL logging to see what really runs. With these basics, Spring Boot, JPA and Hibernate give you a clean, fast and reliable data layer.
20. Frequently Asked Questions (FAQ)
Q1. What is the difference between JPA and Hibernate?
JPA is a specification: a set of standard annotations, interfaces and a query language for mapping Java objects to database tables. Hibernate is a library that implements JPA and actually generates and runs the SQL. In Spring Boot, you write code against JPA, and Hibernate does the work underneath.
Q2. Which Hibernate version does Spring Boot use?
Spring Boot manages the version for you. Spring Boot 3 uses Hibernate ORM 6 with the jakarta.persistence package, and Spring Boot 4 uses Hibernate ORM 7. Older Spring Boot 2 applications used Hibernate 5 and the javax.persistence package.
Q3. Do I need to configure the Hibernate dialect?
Usually not. Hibernate detects the database from the JDBC connection and picks the right dialect automatically. Only set spring.jpa.database-platform if detection is not possible, for example when the database is not reachable at startup.
Q4. Why does an entity need a no-argument constructor?
Hibernate creates entity objects by reflection when it reads rows from the database, so it needs a constructor without parameters. It can be protected, which keeps the rest of your code from creating half-empty objects.
Q5. Which ID generation strategy should I use?
Use SEQUENCE for databases with sequences, such as PostgreSQL and Oracle, because it supports batch inserts. IDENTITY is simple and common with MySQL, but disables insert batching. Use UUID when IDs must be unique across systems or created before saving.
Q6. What is LazyInitializationException and how do I fix it?
It happens when you access a lazy relationship after the transaction and persistence context have closed, for example in a controller. Fix it by loading the needed data inside the transaction with JOIN FETCH, an @EntityGraph or a DTO query. Switching to EAGER or enabling open-in-view only hides the problem.
Q7. Should I use FetchType.EAGER or LAZY?
Prefer LAZY for all associations, and load related data explicitly in the queries that need it. @ManyToOne and @OneToOne are eager by default, so set fetch = FetchType.LAZY on them yourself.
Q8. What does mappedBy mean?
mappedBy marks the inverse side of a two-way relationship. It names the field on the other entity that owns the foreign key. Only the owning side (with @JoinColumn) is used to write the foreign key, so always set that side when linking entities.
Q9. What is the difference between cascade remove and orphanRemoval?
CascadeType.REMOVE deletes the children when the parent is deleted. orphanRemoval = true also deletes a child when it is removed from the parent’s collection, even though the parent remains. Use both for children that cannot exist without their parent, such as order items.
Q10. Why are my changes saved even though I never called save()?
Inside a transaction, entities you load are managed. Hibernate compares them with their original state at commit and writes any changes automatically. This is called dirty checking. If you do not want changes saved, work on a copy or use a read-only transaction.
Q11. What is the persistence context?
It is the set of entities Hibernate is currently tracking, usually one per transaction. It acts as a first-level cache, so loading the same entity twice returns the same object, and it records changes so they can be written at commit.
Q12. What is the N+1 query problem?
Loading N entities with one query and then accessing a lazy relationship on each one triggers N extra queries. With 100 orders, you get 101 queries instead of 1 or 2. Fix it with JOIN FETCH, @EntityGraph, batch fetching or a projection.
Q13. What is the difference between JPQL and native SQL?
JPQL uses entity and field names and works on any database that Hibernate supports. Native SQL uses table and column names and can use database-specific features, but ties the query to one database and is not checked against your entities. Prefer JPQL unless you need a special SQL feature.
Q14. Which ddl-auto value should I use in production?
Use validate or none, and change the schema with versioned migrations in Flyway or Liquibase. Never use create or create-drop in production, because they delete data, and avoid update, because it is unpredictable and leaves no history.
Q15. How can I see the SQL that Hibernate runs?
Enable the Hibernate loggers:
logging:
level:
org.hibernate.SQL: debug
org.hibernate.orm.jdbc.bind: trace
The first shows each statement and the second shows parameter values. Add spring.jpa.properties.hibernate.format_sql=true for readable formatting.
Q16. Should I set spring.jpa.open-in-view to false?
Yes, for most applications. When it is on (the default), the database session stays open until the web response is written, so lazy loading can run queries from controllers and views without you noticing. Turning it off keeps all database access inside the service layer.
Q17. How does @Version prevent lost updates?
Hibernate stores a version number in each row and adds WHERE version = ? to every update. If another transaction changed the row first, the version no longer matches, no row is updated, and an ObjectOptimisticLockingFailureException is thrown instead of silently overwriting the data.
Q18. How do I insert many rows quickly with Hibernate?
Enable JDBC batching with hibernate.jdbc.batch_size and order_inserts, use SEQUENCE or UUID IDs (batching does not work with IDENTITY), and call flush() and clear() every batch so the persistence context does not grow too large.
Q19. Should I use Lombok @Data on JPA entities?
No. @Data generates equals(), hashCode() and toString() from all fields, which can trigger lazy loading, cause endless loops between related entities, and break entities stored in sets. Use @Getter, write equals and hashCode based on the ID carefully, and exclude relationships from toString.
Q20. Can I return JPA entities directly from a REST controller?
It works for simple cases, but it is better to return DTOs or projections. Entities can expose internal fields, trigger lazy-loading errors or extra queries during JSON serialization, and tie your API format to your database structure.