JPA Tutorial: Learn Java Persistence from Scratch (2026)
Jakarta Persistence API (JPA) is the standard Java specification for object-relational mapping. It defines annotations and interfaces for mapping Java objects to database tables, managing entity lifecycles, and querying with JPQL. Understanding JPA as a specification separate from any implementation (Hibernate, EclipseLink, OpenJPA) lets you write vendor-neutral persistence code.
This tutorial focuses on the JPA specification itself — entities, entity manager, JPQL, criteria API, locking, entity graphs, and best practices. Whether you use Hibernate or another provider, the concepts remain the same.
Entity Manager and Persistence Context
The EntityManager is the central interface for all persistence operations. It manages the persistence context — a first-level cache of managed entities within a transaction. EntityManagerFactory is a thread-safe factory that creates EntityManager instances. In Java SE, configure persistence via META-INF/persistence.xml; in Spring Boot, the factory is auto-configured.
Entity lifecycle states: NEW (not persisted), MANAGED (attached to a persistence context), DETACHED (was managed but context closed), REMOVED (scheduled for deletion). Understanding these states prevents confusing behaviour like unexpected lazy initialization exceptions.
EntityManagerFactory emf = Persistence.createEntityManagerFactory("my-pu");
EntityManager em = emf.createEntityManager();
try {
em.getTransaction().begin();
Product product = em.find(Product.class, 1L); // MANAGED
product.setPrice(BigDecimal.valueOf(29.99));
em.getTransaction().commit();
} finally {
em.close();
}
JPQL: The JPA Query Language
JPQL is an object-oriented query language similar to SQL but operating on entity objects and their fields rather than tables and columns. SELECT c FROM Customer c WHERE c.email LIKE :email returns entities, not raw column data. Named queries defined on entities via @NamedQuery are parsed at startup and more performant than ad-hoc query strings.
Use TypedQuery for type-safe results and set parameters by name rather than position. For bulk updates and deletes, use executeUpdate within a transaction — but be aware that the persistence context may become stale and should be cleared after bulk operations.
@Entity
@NamedQuery(
name = "Order.findByStatus",
query = "SELECT o FROM Order o WHERE o.status = :status ORDER BY o.createdAt DESC"
)
public class Order { ... }
TypedQuery query = em.createNamedQuery("Order.findByStatus", Order.class);
query.setParameter("status", OrderStatus.PENDING);
List pendingOrders = query.getResultList();
Criteria API
The Criteria API builds queries programmatically using Java objects, avoiding string concatenation errors and providing compile-time type safety. Use CriteriaBuilder to construct expressions, predicates, and order clauses. The CriteriaQuery object represents the query structure and is executed via EntityManager.
The JPA Metamodel generates static metamodel classes (Order_) that make criteria queries fully type-safe. For dynamic queries with optional filters — common in search screens — criteria queries are far cleaner than string-building JPQL.
CriteriaBuilder cb = em.getCriteriaBuilder();
CriteriaQuery query = cb.createQuery(Product.class);
Root root = query.from(Product.class);
List predicates = new ArrayList<>();
if (category != null) {
predicates.add(cb.equal(root.get(Product_.category), category));
}
if (minPrice != null) {
predicates.add(cb.greaterThanOrEqualTo(root.get(Product_.price), minPrice));
}
query.where(predicates.toArray(new Predicate[0]));
query.orderBy(cb.desc(root.get(Product_.createdAt)));
List result = em.createQuery(query).getResultList();
Locking and Concurrency
JPA supports optimistic and pessimistic locking. Optimistic locking uses a @Version field (typically an integer or timestamp) — Hibernate checks that the version has not changed before writing and throws OptimisticLockException on conflict. This is the default and works well for most applications with low contention.
Pessimistic locking acquires database-level locks: PESSIMISTIC_READ (shared lock) and PESSIMISTIC_WRITE (exclusive lock). Use pessimistic locking for high-contention scenarios or when correctness requires preventing concurrent modifications within a single transaction.
@Entity
public class Inventory {
@Id
private Long productId;
@Version
private int version;
private int quantity;
}
try {
Product product = em.find(Product.class, id, LockModeType.PESSIMISTIC_WRITE);
product.decrementStock(quantity);
} catch (OptimisticLockException e) {
// Retry logic or user-facing error
}
// Named lock timeout
@QueryHints(@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000"))
Entity Graphs for Dynamic Fetching
Entity graphs let you specify which associations to fetch at query time without changing the entity's default fetch type. Define a graph via @NamedEntityGraph on the entity or build one programmatically via EntityManager.createEntityGraph(). Graphs can be fetched (override fetch types) or load-only (add additional eager fetches).
This is the cleanest solution to the N+1 problem for ad-hoc queries. Instead of hardcoding JOIN FETCH in JPQL, the caller decides the fetch depth. Combined with lazy defaults, entity graphs give you both safety and flexibility.
@Entity
@NamedEntityGraph(name = "Order.withItemsAndPayment",
attributeNodes = {
@NamedAttributeNode("items"),
@NamedAttributeNode(value = "payment", subgraph = "payment.billing")
},
subgraphs = @NamedSubgraph(
name = "payment.billing",
attributeNodes = @NamedAttributeNode("billingAddress")
)
)
public class Order { ... }
EntityGraph> graph = em.getEntityGraph("Order.withItemsAndPayment");
Map hints = Map.of("jakarta.persistence.fetchgraph", graph);
Order order = em.find(Order.class, id, hints);
Persistence XML Configuration
In Java SE environments, configure JPA via META-INF/persistence.xml. This file declares the persistence-unit name, transaction type, data source properties, and entity classes. In Java EE or Spring Boot, the container manages these settings, but understanding persistence.xml is essential for standalone applications and testing.
The JPA provider can be Hibernate, EclipseLink, or OpenJPA. Specify the provider class in persistence.xml along with provider-specific properties like SQL dialect, DDL auto-generation, and logging.
org.hibernate.jpa.HibernatePersistenceProvider
com.example.Order
com.example.Customer
Frequently Asked Questions
What is the difference between JPA and Hibernate?
JPA is the specification — a set of interfaces and annotations. Hibernate is an implementation of that specification. Hibernate also offers proprietary features like Hibernate Search, multitenancy, and batch processing that go beyond the JPA standard.
When should I use JPQL vs the Criteria API?
Use JPQL for static, well-understood queries — it is more readable. Use the Criteria API for dynamic queries built programmatically at runtime, such as filter forms with optional conditions. Avoid mixing both styles in the same codebase.
What is the purpose of @Version?
@Version enables optimistic locking. JPA increments the version field automatically on every update. Before applying an update, JPA checks the version is unchanged since the entity was read; if another transaction changed it, OptimisticLockException is thrown.
How do I map enums in JPA?
Use @Enumerated(EnumType.STRING) to store the enum name as a readable string. Avoid EnumType.ORDINAL — inserting a new enum value in the middle shifts all ordinals, corrupting existing data. For custom values, implement AttributeConverter.
Originally published on Ayodhyyya. Last updated June 1, 2026.