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);
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.
| Layer | What it actually is | Responsibility | You'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; }
}
recordThis 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
| Strategy | How it works | Trade-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 |
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 inA 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);
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; }
}
@ManyToOne owns the foreign key — mistaking this breaks writes silentlyWhichever 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 type | Propagates | Common mistake |
|---|---|---|
PERSIST | em.persist() to children when the parent is persisted | Safe and usually what you want on @OneToMany |
MERGE | em.merge() to children | Rarely needed alone |
REMOVE | Deletes children when the parent is deleted | Dangerous on @ManyToOne — deleting one Order should never cascade-delete the shared Customer |
REFRESH | Reloads children from the DB when the parent is refreshed | Rare, used to discard uncommitted in-memory changes |
ALL | All of the above | The 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.
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.
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
}
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 */ }
}
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.LAZYexplicitly on every@ManyToOne/@OneToOne— never rely on the EAGER default - Set
spring.jpa.open-in-view=falsein 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
@EntityGraphwhenever you know a collection will be accessed - Use
@Versionoptimistic locking on any entity subject to concurrent updates - Prefer
SEQUENCEoverIDENTITYwhen the database supports it, to keep batch inserts working
❌ Don't
- Don't expose
@Entityclasses as REST response bodies — bidirectional relationships cause infinite serialization loops and leak persistence details into the API contract - Don't use Lombok's
@Dataor@EqualsAndHashCodeon entities — it generates field-based equality that forces lazy associations to load - Don't put an entity in a
HashSetbefore it has a stable identity or business key - Don't cascade
REMOVEfrom 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-viewat its default without understanding what it's hiding
Interview Questions
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.
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.