JPA — Java Persistence API

The specification behind Hibernate and Spring Data JPA — entity mapping, the persistence context, N+1 queries, Open Session in View, and why an entity should never be a record

← Back to Index

What is JPA — and Why Does It Matter?

JPA (Jakarta Persistence API, formerly Java Persistence API) is a specification — a set of interfaces and annotations in jakarta.persistence.* with no working implementation behind them. It standardizes how a Java object graph maps to relational database tables (Object-Relational Mapping) and defines a query language, JPQL, that operates on entities instead of table rows. JPA on its own does not execute a single SQL statement — it needs a provider that implements the spec. Hibernate is the dominant provider; EclipseLink and OpenJPA are the others still in active use.

Before JPA (2006), every ORM vendor had its own proprietary API — Hibernate 2.x had one API, TopLink had another. Switching providers meant rewriting data access code. JPA fixed that by giving providers a common contract to implement, the same way JDBC gave database vendors a common contract for drivers.

// BEFORE JPA — raw JDBC: you manage the connection, the mapping, and cleanup
Connection conn = DriverManager.getConnection(url);
PreparedStatement stmt = conn.prepareStatement(
    "SELECT * FROM customers WHERE id = ?");
stmt.setLong(1, customerId);
ResultSet rs = stmt.executeQuery();
Customer customer = null;
if (rs.next()) {
    customer = new Customer();
    customer.setId(rs.getLong("id"));
    customer.setName(rs.getString("full_name"));
}
rs.close(); stmt.close(); conn.close();

// AFTER JPA — mapping, connection handling, and resource cleanup are gone
Customer customer = em.find(Customer.class, customerId);
JPA is a specification, not a library you can run

When you write @Entity or call EntityManager.persist(), you are calling into an interface. At runtime, a provider (almost always Hibernate) supplies the actual implementation: it generates the SQL, manages a first-level cache, tracks dirty state, and talks to the JDBC driver. Understanding this separation matters the first time you hit a Hibernate-specific feature (like @DynamicUpdate or Hibernate's second-level cache configuration) that isn't part of the JPA spec at all — knowing which layer you're in tells you where to look in the documentation.

The Three Layers: JPA, Hibernate, and Spring Data JPA

This is the single most common source of confusion for developers coming into Spring projects: three names get used almost interchangeably, but they are three distinct layers stacked on top of each other.

LayerWhat it actually isResponsibilityYou'll recognize it by
JPA Specification (interfaces + annotations, zero logic) Defines @Entity, @Id, the EntityManager contract, and the JPQL grammar jakarta.persistence.* imports
Hibernate A JPA provider — the concrete implementation doing the work Generates SQL, manages the first-level cache and dirty checking, executes queries against JDBC org.hibernate.* imports, hibernate.hbm2ddl.auto config
Spring Data JPA An abstraction on top of a JPA provider Generates repository implementations from interface method names, adds paging/sorting, wraps EntityManager calls in boilerplate you never see JpaRepository<Customer, Long>, @Query

Spring Data JPA does not replace Hibernate — it needs a JPA provider underneath it to function, and that provider is Hibernate by default in every Spring Boot starter. When CustomerRepository extends JpaRepository<Customer, Long> generates a working implementation from nothing but a method signature, it is Spring Data JPA parsing that method name, building a JPQL query, and delegating execution to the underlying EntityManager — which is, in turn, backed by Hibernate. Everything covered on this page is the middle and bottom layers: pure JPA and the concepts Hibernate implements underneath it. See Spring Data JPA for the repository abstraction layer on top.

Entities — Mapping Java Objects to Tables

An entity is a plain Java class annotated with @Entity that represents a row in a table. JPA imposes a small set of hard requirements on the class, and violating any of them either fails at startup or fails silently at runtime in ways that are painful to debug.

import jakarta.persistence.*;

@Entity
@Table(name = "customers")
public class Customer {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "full_name", length = 100, nullable = false)
    private String name;

    @Column(unique = true, nullable = false)
    private String email;

    @Column(name = "created_at", updatable = false)
    private Instant createdAt;

    // Required by JPA: a no-arg constructor, package-private or higher.
    // Hibernate uses it (via reflection) to instantiate entities before
    // populating fields — you never call this yourself.
    protected Customer() { }

    public Customer(String name, String email) {
        this.name = name;
        this.email = email;
        this.createdAt = Instant.now();
    }

    public Long getId() { return id; }
    public String getName() { return name; }
    public void setName(String name) { this.name = name; }
    public String getEmail() { return email; }
}
An entity must never be a record

This project's convention is "always use record for DTOs" — and that convention explicitly excludes entities. A record is implicitly final, has no no-arg constructor, and its fields are immutable. Hibernate needs to: (1) subclass the entity to create lazy-loading proxies for relationships — impossible on a final class; (2) instantiate the entity via a no-arg constructor and populate fields afterward via reflection — impossible without a no-arg constructor and mutable fields; (3) mutate fields directly for dirty checking on managed entities. Entities are mutable, non-final, framework-managed objects by design. Records are the right tool for query projections and API responses — never for @Entity classes.

Primary Key Generation Strategies

StrategyHow it worksTrade-off
IDENTITY Delegates to the database's auto-increment column Simple and widely supported, but disables JDBC batch inserts in Hibernate — the ID must be known immediately after each insert, so each row is a separate round-trip
SEQUENCE Uses a database sequence object, fetched in batches Preferred on PostgreSQL/Oracle — allows Hibernate to batch inserts because IDs are pre-allocated client-side
TABLE Simulates a sequence using a dedicated table with row locking Portable across any database but the slowest option due to lock contention — avoid unless the database genuinely lacks sequence support
AUTO Provider picks a strategy based on the dialect Convenient for prototypes; pin an explicit strategy before production so behavior doesn't silently change with a dialect upgrade
equals() and hashCode() on entities — the trap that breaks Sets

Using the auto-generated id field for equals() is the natural instinct and the wrong default. A transient entity (not yet persisted) has id == null, so two different new entities are "equal" until persisted — breaking any HashSet<Customer> built before the flush. The safer pattern is a stable business key (e.g. email) or, for entities with no natural key, overriding equals()/hashCode() based on a constant when the entity is used in collections only after persistence. Never use Lombok's @Data on an entity — it generates equals()/hashCode() over every field, which triggers full lazy-loading of every relationship the moment the entity is put in a HashSet or compared.

EntityManager and the Persistence Context

The EntityManager is the runtime interface to the persistence context — an in-memory first-level cache of managed entities scoped to a transaction. Every entity loaded through it is tracked: modify a field, and the change is written to the database at flush time with no explicit "save" call required.

@Stateless
public class CustomerRepository {

    @PersistenceContext
    private EntityManager em;

    public void createCustomer(Customer customer) {
        em.persist(customer);                       // INSERT — assigns the ID
    }

    public Customer findCustomer(Long id) {
        return em.find(Customer.class, id);   // SELECT, or returns from cache if already managed
    }

    public void renameCustomer(Long id, String newName) {
        Customer customer = em.find(Customer.class, id);
        customer.setName(newName);                  // no em.update() — dirty checking
    }                                                // detects the change and issues UPDATE at flush time

    public void deleteCustomer(Long id) {
        Customer customer = em.find(Customer.class, id);
        if (customer != null) em.remove(customer);  // DELETE at flush time
    }
}

Entity Lifecycle

NEW (Transient)          — created with `new`, not tracked by JPA
    ↓ em.persist()
MANAGED (Persistent)     — tracked; field changes flush to the DB automatically
    ↓ em.detach() / transaction ends / em.close()
DETACHED                 — no longer tracked; changes are silently ignored
    ↓ em.merge()          returns a new MANAGED copy — the original stays detached
MANAGED (again, on the merged copy)
    ↓ em.remove()
REMOVED                  — scheduled for deletion
    ↓ transaction commits
Deleted from the database
merge() does not mutate the object you pass in

A common bug: calling em.merge(detachedCustomer) and then continuing to use detachedCustomer, expecting it to now be managed. It isn't. merge() copies the state onto a (possibly newly loaded) managed entity and returns that — the argument you passed in stays detached. Always use the return value: customer = em.merge(customer);

Transaction-scoped vs extended persistence context

The default (and almost always correct) choice is a transaction-scoped persistence context — it exists only for the duration of a single transaction and is cleared afterward. An extended context, requested via @PersistenceContext(type = PersistenceContextType.EXTENDED), survives across multiple transactions and is only valid on a @Stateful EJB, since it needs somewhere stateful to live. It exists mainly to support long-running conversations (a multi-step checkout wizard editing one entity across several requests) — a niche use case that Spring's equivalent (OSIV, covered below) solves differently and with different trade-offs.

Relationships — Modeling Associations Between Entities

@OneToMany / @ManyToOne — bidirectional, with consistency helpers

@Entity
public class Customer {
    @Id @GeneratedValue
    private Long id;

    // mappedBy = the INVERSE side. It does not own the foreign key —
    // it only mirrors what @ManyToOne declares on Order.
    @OneToMany(mappedBy = "customer", cascade = CascadeType.PERSIST, orphanRemoval = true)
    private List<Order> orders = new ArrayList<>();

    // Helper methods keep both sides of the relationship in sync —
    // forgetting this is the #1 bidirectional-relationship bug.
    public void addOrder(Order order) {
        orders.add(order);
        order.setCustomer(this);
    }

    public void removeOrder(Order order) {
        orders.remove(order);
        order.setCustomer(null);
    }
}

@Entity
@Table(name = "orders")
public class Order {
    @Id @GeneratedValue
    private Long id;
    private BigDecimal total;

    // @ManyToOne is the OWNING side — it holds the foreign key column.
    @ManyToOne(fetch = FetchType.LAZY)
    @JoinColumn(name = "customer_id", nullable = false)
    private Customer customer;

    void setCustomer(Customer customer) { this.customer = customer; }
}
Only @ManyToOne owns the foreign key — mistaking this breaks writes silently

Whichever side does not have mappedBy is the owning side, and only the owning side's state is written to the database. If you set order.setCustomer(customer) you get a working foreign key. If you only do customer.getOrders().add(order) without also setting the back-reference, JPA writes nothing — the collection update is silently ignored because Customer is the inverse (mappedBy) side. This is precisely why the addOrder()/removeOrder() helper pattern above exists — it makes it impossible to update one side and forget the other.

Cascade Types — what actually propagates

Cascade typePropagatesCommon mistake
PERSISTem.persist() to children when the parent is persistedSafe and usually what you want on @OneToMany
MERGEem.merge() to childrenRarely needed alone
REMOVEDeletes children when the parent is deletedDangerous on @ManyToOne — deleting one Order should never cascade-delete the shared Customer
REFRESHReloads children from the DB when the parent is refreshedRare, used to discard uncommitted in-memory changes
ALLAll of the aboveThe single most common source of accidental cascading deletes in code review — apply cascades individually and deliberately, not as a default

@ManyToMany — with an explicit join entity as the modern default

// The classic @ManyToMany + @JoinTable works, but the moment the join
// table needs its own data (e.g. quantity and price at time of sale), you
// need an explicit join entity anyway. Most teams model it explicitly from
// the start: an Order is linked to many Products through OrderItem rows.
@Entity
@Table(name = "order_items")
public class OrderItem {
    @EmbeddedId
    private OrderItemId id;

    @ManyToOne(fetch = FetchType.LAZY) @MapsId("orderId")
    private Order order;

    @ManyToOne(fetch = FetchType.LAZY) @MapsId("productId")
    private Product product;

    private int quantity;              // extra columns plain @ManyToMany can't express
    private BigDecimal unitPrice;   // captured at purchase time, not the live catalog price
}

Fetching Strategies and the N+1 Query Problem

@OneToMany and @ManyToMany default to FetchType.LAZY; @ManyToOne and @OneToOne default to FetchType.EAGER. That EAGER default on @ManyToOne is one of JPA's most-criticized design decisions, and it directly causes the most common performance bug in every JPA codebase: N+1 queries.

// 1 query to fetch all customers, then N additional queries — one per
// customer — the first time .getOrders() is touched inside the loop:
List<Customer> customers = em.createQuery("SELECT c FROM Customer c", Customer.class)
                                  .getResultList();          // query #1

for (Customer c : customers) {
    System.out.println(c.getOrders().size());  // queries #2..N+1
}
// 100 customers → 101 round-trips to the database instead of 1 or 2.

The fix is a fetch join, which retrieves the association in the same query using a single SQL JOIN:

List<Customer> customers = em.createQuery(
    "SELECT DISTINCT c FROM Customer c LEFT JOIN FETCH c.orders",
    Customer.class).getResultList();
// 1 query total. DISTINCT is required — the join multiplies customer rows
// once per order, and Hibernate needs it to de-duplicate the Java result.
You cannot fetch-join two collections in the same query

Fetch-joining more than one @OneToMany/@ManyToMany collection at once produces a MultipleBagFetchException in Hibernate — the cartesian product between two collections makes the result set ambiguous to map back into Java objects. Fetch one collection per query, use an @EntityGraph (Spring Data JPA), or switch the collection type from List to Set, which allows fetching two at once at the cost of losing the ability to remove by index.

Default EAGER on @ManyToOne is almost always wrong

Explicitly set fetch = FetchType.LAZY on every @ManyToOne and @OneToOne unless you have a specific reason not to. The EAGER default means every query that loads an Order silently also loads its Customer — and if that Customer has its own eager relationships, the chain keeps growing. Lazy-by-default and explicit fetch joins where needed gives you control over exactly what gets loaded, instead of discovering the real query shape by reading Hibernate's SQL log.

Open Session in View (OSIV) — the Setting Everyone Ignores Until It Bites

Spring Boot ships with spring.jpa.open-in-view=true by default, and logs a warning about it on every startup that most developers have learned to ignore. OSIV keeps the persistence context (and its underlying database connection) open for the entire HTTP request, not just for the duration of the @Transactional service method.

// Without OSIV, this throws LazyInitializationException:
@GetMapping("/customers/{id}")
public Customer getCustomer(@PathVariable Long id) {
    Customer customer = customerRepository.findById(id).orElseThrow();
    // the @Transactional method that loaded `customer` has already returned,
    // its persistence context is closed, and `orders` was never fetched
    return customer;   // Jackson serialization touches customer.getOrders() here → boom
}
Why OSIV = true feels convenient and why that's the problem

With OSIV enabled the example above just works — the connection stays open through serialization, so getOrders() silently triggers a lazy-load query at the last possible moment. That convenience is exactly the trap: it means N+1 queries can now happen inside the view/serialization layer, invisible to the service layer where developers actually look for performance problems, and every request holds a database connection open for its entire duration (including template rendering or JSON serialization time), reducing the effective size of your connection pool under load.

The production-grade fix is spring.jpa.open-in-view=false combined with fetching exactly what each endpoint needs — a fetch join or @EntityGraph in the repository method, or (better for read endpoints) a DTO projection that never touches the entity's lazy associations at all:

// DTO projection: no entity, no lazy loading, no OSIV dependency.
public record CustomerSummary(Long id, String name, long orderCount) {}

@Query("""
    SELECT new com.example.CustomerSummary(c.id, c.name, COUNT(o))
    FROM Customer c LEFT JOIN c.orders o
    GROUP BY c.id, c.name
    """)
List<CustomerSummary> summarize();

JPQL, the Criteria API, and Native SQL

JPQL — operates on entities and their fields, not tables and columns

List<Order> largeOrders = em.createQuery(
    "SELECT o FROM Order o WHERE o.total >= :minTotal ORDER BY o.placedAt", Order.class)
    .setParameter("minTotal", new BigDecimal("100.00"))
    .setFirstResult(0)
    .setMaxResults(20)
    .getResultList();

Long count = em.createQuery("SELECT COUNT(o) FROM Order o", Long.class)
                  .getSingleResult();

The Criteria API — type-safe queries built at compile time

JPQL is a string — typos and field renames only surface at runtime. The Criteria API builds the same query as a type-safe object graph, checked by the compiler, at the cost of considerably more verbose code:

CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery<Order> query = cb.createQuery(Order.class);
Root<Order> root = query.from(Order.class);

query.select(root)
     .where(cb.greaterThanOrEqualTo(root.get("total"), new BigDecimal("100.00")))
     .orderBy(cb.asc(root.get("placedAt")));

List<Order> largeOrders = em.createQuery(query).getResultList();
// Most teams reserve Criteria for dynamic queries built from optional
// filters (search forms); JPQL for everything with a fixed shape.

Native SQL — the escape hatch, used sparingly

List<Customer> customers = em.createNativeQuery(
    "SELECT * FROM customers WHERE email LIKE ?1", Customer.class)
    .setParameter(1, "%@gmail.com")
    .getResultList();
// Loses database portability and JPQL's compile-time entity awareness —
// reach for it only for vendor-specific features JPQL can't express
// (window functions, full-text search, database-specific hints).

Concurrency: Optimistic and Pessimistic Locking

Optimistic locking — the default and correct choice for most apps

@Entity
public class Product {
    @Id @GeneratedValue
    private Long id;
    private int stockQuantity;

    @Version  // Hibernate adds "WHERE version = ?" to every UPDATE
    private Long version;
}

// If two checkout transactions load the same product row and both try to
// decrement stock, the second commit's WHERE version = ? matches zero rows
// and Hibernate throws OptimisticLockException — no row was silently
// overwritten. The caller decides whether to retry or fail the request.

Pessimistic locking — when lost updates are unacceptable and retries aren't viable

Product product = em.find(Product.class, id, LockModeType.PESSIMISTIC_WRITE);
// Issues SELECT ... FOR UPDATE — the database row-locks until the
// transaction commits. Other transactions requesting the same lock block.
// Trades throughput for a hard guarantee against concurrent writes —
// reach for it in payment capture, seat reservations, and inventory
// decrements where a lost update is not an acceptable outcome.

Entity Lifecycle Callbacks

@Entity
public class Product {
    @Id @GeneratedValue
    private Long id;
    private String name;
    private Instant createdAt;
    private Instant updatedAt;

    @PrePersist
    void onCreate() { createdAt = Instant.now(); }

    @PreUpdate
    void onUpdate() { updatedAt = Instant.now(); }

    @PostLoad
    void afterLoad() { /* e.g. warm a transient computed field */ }
}
Callbacks are for entity-local bookkeeping only

Timestamps and simple field derivation belong here. Anything that calls another service, sends an event, or touches a different aggregate does not — that logic belongs in the service layer, inside the transaction that triggered the change. Lifecycle callbacks that reach out to other beans are a common source of hidden, hard-to-test coupling.

Best Practices and Common Pitfalls

✅ Do

  • Set fetch = FetchType.LAZY explicitly on every @ManyToOne/@OneToOne — never rely on the EAGER default
  • Set spring.jpa.open-in-view=false in every Spring Boot project and fetch exactly what each use case needs
  • Return DTOs (records) from service and controller boundaries — never serialize entities directly
  • Use fetch joins or @EntityGraph whenever you know a collection will be accessed
  • Use @Version optimistic locking on any entity subject to concurrent updates
  • Prefer SEQUENCE over IDENTITY when the database supports it, to keep batch inserts working

❌ Don't

  • Don't expose @Entity classes as REST response bodies — bidirectional relationships cause infinite serialization loops and leak persistence details into the API contract
  • Don't use Lombok's @Data or @EqualsAndHashCode on entities — it generates field-based equality that forces lazy associations to load
  • Don't put an entity in a HashSet before it has a stable identity or business key
  • Don't cascade REMOVE from the "many" side of a @ManyToOne — that deletes a shared parent
  • Don't fetch-join two collections in the same JPQL query — restructure or use Set
  • Don't leave open-in-view at its default without understanding what it's hiding

Interview Questions

🎓 Junior level

Q: What is the difference between JPA and Hibernate?
JPA is a specification — interfaces and annotations with no runtime logic. Hibernate is a concrete provider that implements that specification. Code written against jakarta.persistence.EntityManager can, in theory, run against any JPA provider without changes.

Q: What's the difference between persist(), merge(), and remove()?
persist() makes a transient (new) entity managed and schedules an INSERT. merge() copies the state of a detached entity onto a managed one (loading it first if necessary) and returns that managed copy — the argument stays detached. remove() schedules a managed entity for deletion.

Q: What is lazy loading?
An association marked FetchType.LAZY is not loaded from the database until the code actually accesses it (e.g. calling .getOrders()). It's implemented via a runtime proxy that triggers the query on first access.

🔥 Senior level

Q: Explain the N+1 problem and two different ways to fix it.
Loading a list of N parent entities and then accessing a lazy association on each one inside a loop triggers 1 query for the parents plus N additional queries — one per parent — for the association. Fix #1: a JPQL fetch join (LEFT JOIN FETCH) retrieves everything in a single query via SQL JOIN. Fix #2: a DTO projection query that never touches the lazy association at all, aggregating in SQL instead of Java. Fetch joins keep the entity graph; DTO projections avoid loading entities entirely and are generally preferable for read-only endpoints.

Q: What is Open Session in View, and why do many senior engineers disable it in production?
OSIV keeps the persistence context (and a database connection) open for the full HTTP request instead of just the transactional service method. It's convenient — lazy associations can still be accessed during JSON serialization — but it hides N+1 queries inside the view layer, holds connections open longer than necessary (shrinking the effective pool size under load), and encourages controllers to depend on lazy loading instead of fetching exactly what they need. Disabling it (open-in-view=false) forces every endpoint to fetch its data explicitly, at the cost of an immediate LazyInitializationException the first time an endpoint accidentally relies on the old behavior — which is precisely the bug it's designed to surface.

Q: Why should an entity never be a record?
Hibernate needs to generate proxy subclasses for lazy-loaded associations (impossible on an implicitly final record), instantiate the entity via a no-arg constructor and populate fields via reflection (records have neither), and mutate fields in place for dirty checking (record fields are immutable). Records are the right shape for DTOs and query projections, which are read-once and thrown away — the opposite of an entity's mutable, tracked lifecycle.

Q: Why is GenerationType.IDENTITY often a worse default than SEQUENCE in high-throughput systems?
With IDENTITY, the database assigns the primary key on insert, so Hibernate must execute (and flush) each insert individually to learn the generated ID — batch inserts are effectively disabled. SEQUENCE lets Hibernate pre-allocate a block of IDs client-side (via allocationSize), so it can batch multiple inserts into fewer round-trips before ever hitting the database.