00:00

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?

TermWhat it isExample
ORM (Object-Relational Mapping)The technique of mapping Java classes to database tables and objects to rowsA 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
HibernateThe most popular implementation of JPA; it generates and runs the SQLHibernate ORM 6 in Spring Boot 3
Spring Data JPAA Spring layer on top of JPA that generates repositoriesJpaRepository

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:

What Spring Boot creates for you
  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 @Entity classes.
  • Hibernate detects the database type and chooses the right SQL dialect automatically.
  • Every setting can be changed with spring.jpa.* and spring.datasource.* properties.

3. Setting Up the Project

pom.xml
<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>
application.yml
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

Product.java
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; }
}
Matching table (PostgreSQL)
CREATE TABLE products (
    id     BIGSERIAL PRIMARY KEY,
    name   VARCHAR(120)   NOT NULL,
    price  NUMERIC(10, 2) NOT NULL,
    stock  INTEGER        NOT NULL
);
AnnotationPurpose
@EntityMarks the class as a persistent entity
@TableSets the table name; without it, the class name is used
@IdMarks the primary key
@GeneratedValueTells Hibernate how the key is generated
@ColumnSets 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

StrategyHow it worksNotes
IDENTITYThe database generates the key with an auto-increment columnSimple; but Hibernate cannot batch inserts, because it must insert each row to learn its ID
SEQUENCEHibernate gets IDs from a database sequence before insertingBest for PostgreSQL and Oracle; supports batch inserts
UUIDHibernate generates a random UUIDUnique across systems; larger keys and less index-friendly
AUTOHibernate chooses a strategy for the databaseExplicit choices are clearer
SEQUENCE and UUID examples
// 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

Customer.java
@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() {
    }
}
Address.java
@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 annotationDatabase mapping
StringVARCHAR (length 255 unless set)
BigDecimalNUMERIC; set precision and scale for money
LocalDate, LocalDateTime, InstantDATE and TIMESTAMP columns
@Enumerated(EnumType.STRING)The enum name as text; safer than the default ordinal number
@Embedded / @EmbeddableA value object whose fields become columns of the owning table
@LobLarge text or binary data
@TransientA 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:

Tables
  categories                products
  ----------                --------------------------
  id  (PK)    <-------      category_id (FK)
  name                      id (PK), name, price, stock
Category.java
@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);
    }
}
Product.java (relationship part)
@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;
}
AnnotationMeaningDefault fetch
@ManyToOneMany products belong to one categoryEAGER
@OneToManyOne category has many productsLAZY
@OneToOneOne user has one profileEAGER
@ManyToManyProducts 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 typeBehaviourRisk
LAZYRelated data is loaded only when you first access itLazyInitializationException if accessed after the transaction ends
EAGERRelated data is always loaded with the entityLoads 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:

PurchaseOrder.java
@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
    }
}
OrderItem.java
@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:

Entity lifecycle
   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)
Dirty checking in action
@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:

ProductReportDao.java
@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 methodPurpose
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:

JPQL examples
// 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"
CategoryStats.java
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:

The problem
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
FixHowBest for
JOIN FETCHSELECT o FROM PurchaseOrder o JOIN FETCH o.itemsOne specific query
@EntityGraphAnnotate a repository method with the relationships to loadSpring Data repositories
Batch fetchinghibernate.default_batch_fetch_size: 50 loads lazy data in groupsA simple global improvement
Projections / DTOsSelect only the needed columns with a JPQL constructor or interface projectionRead-only screens and APIs
Fix with @EntityGraph
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:

Version field
@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.

Handling a conflict
@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-autoWhat Hibernate does at startupUse for
noneNothingProduction with a migration tool
validateChecks that tables and columns match the entities; fails if notProduction and staging
updateAdds missing tables and columns; never removes anythingQuick prototypes only
createDrops and recreates the schemaThrowaway local databases
create-dropLike create, then drops the schema on shutdownTests; 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

application.yml (development)
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)
Sample log output
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

TipHow
Batch inserts and updateshibernate.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 needUse projections or DTO queries for lists and reports
Avoid N+1 queriesJOIN FETCH, @EntityGraph or batch fetching
Paginate large resultsUse Pageable instead of loading whole tables
Index your queriesAdd database indexes for columns used in WHERE, JOIN and ORDER BY
Cache rarely changing dataHibernate’s second-level cache with a provider such as JCache, for read-mostly entities
Inserting many rows efficiently
@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

DoAvoid
Make all associations LAZYFetchType.EAGER on relationships
Use EnumType.STRING for enumsOrdinal enum columns
Keep both sides of a relationship in sync with helper methodsUpdating only one side in memory
Cascade only from parents to children they ownCascading to shared entities
Use validate plus Flyway or Liquibaseddl-auto=update in production
Set spring.jpa.open-in-view=falseRunning queries while rendering responses
Return DTOs from APIsSerializing entities directly
Add @Version to entities edited by many usersSilent lost updates
Watch the SQL log while developingDiscovering 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.