Senior 6 min · March 05, 2026

JPA N+1 Query Disaster — 1,001 Queries for 100 Orders

100 orders triggered 1,001 SQL queries, 12-second response, 100% CPU.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • JPA maps Java objects to relational tables via annotations
  • Persistence context tracks entities; dirty checking auto-flushes changes
  • Bidirectional relationships need both sides set via a helper method
  • @OneToMany defaults to LAZY; accessing it outside a transaction throws LazyInitializationException
  • N+1 queries occur when lazy collections are accessed in a loop; fix with JOIN FETCH
  • First-level cache is per EntityManager; second-level is shared across sessions
Plain-English First

Imagine you have a filing cabinet full of paper forms (your database), but your job requires working with sticky notes on a whiteboard (Java objects). JPA is the assistant who automatically transfers information between the two without you having to manually copy each field. You work with your sticky notes, and JPA keeps the filing cabinet in sync. That's it — it's a translation layer between your Java world and your database world.

Every production Java application eventually has to talk to a database. Whether you're building an e-commerce platform, a healthcare system, or a SaaS dashboard, you'll spend a significant chunk of your career reading and writing relational data. The traditional approach — writing raw SQL strings inside Java code — works, but it's brittle. Column names change, queries break at runtime, and your business logic drowns in boilerplate JDBC code that has nothing to do with the actual problem you're solving.

JPA, the Java Persistence API, exists to solve exactly this friction. Instead of thinking in rows and columns, you define plain Java classes that map to database tables, and JPA handles the SQL on your behalf. You call methods on objects; JPA translates them into INSERT, SELECT, UPDATE, and DELETE statements automatically. This is called Object-Relational Mapping (ORM), and when you understand it deeply — not just the annotations, but the lifecycle, the session model, and the relationship strategies — you write dramatically cleaner, safer, and faster code.

By the end of this article you'll understand why JPA exists, how the persistence context (the real heart of JPA) actually works, how to model real-world entity relationships correctly, and how to dodge the performance traps that catch even experienced developers off guard. You'll have working code patterns you can drop straight into a Spring Boot or Jakarta EE project today.

The Persistence Context: The One Concept That Unlocks Everything

Most JPA tutorials throw annotations at you immediately. That's backwards. Before you write a single @Entity, you need to understand the persistence context — because every confusing JPA behaviour you'll ever encounter traces back to it.

Think of the persistence context as a short-lived, in-memory snapshot of your database. It's a first-level cache managed by the EntityManager. Any entity you load, persist, or merge within the same EntityManager instance is tracked. JPA watches those objects. The moment you change a field — even without calling any save method — JPA will automatically flush that change to the database at the right moment. This is called 'dirty checking'.

This matters enormously. In Spring, the default scope is one EntityManager per HTTP request (via @Transactional). Load an Order object, change its status, and JPA will write the UPDATE for you when the transaction commits. No save() call required. This feels like magic until something updates unexpectedly — and then it's a nightmare to debug if you didn't know this was happening.

Entities can be in one of four states: Transient (new object, JPA doesn't know about it), Managed (inside the persistence context, being tracked), Detached (was managed, transaction ended), or Removed (scheduled for deletion). Knowing which state your object is in is the difference between confident JPA usage and guesswork.

PersistenceContextDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
import jakarta.persistence.*;
import jakarta.persistence.EntityManager;
import jakarta.persistence.EntityManagerFactory;
import jakarta.persistence.Persistence;

// A minimal runnable JPA example using a persistence.xml
// Works with Hibernate 6+ and H2 in-memory database
public class PersistenceContextDemo {

    public static void main(String[] args) {

        // Bootstrap JPA — in Spring Boot this happens automatically
        EntityManagerFactory emFactory =
                Persistence.createEntityManagerFactory("demo-unit");

        EntityManager em = emFactory.createEntityManager();

        em.getTransaction().begin();

        // --- STATE 1: TRANSIENT ---
        // 'newProduct' is just a regular Java object. JPA has no idea it exists.
        Product newProduct = new Product();
        newProduct.setName("Mechanical Keyboard");
        newProduct.setPrice(149.99);
        System.out.println("State: TRANSIENT — id is null: " + newProduct.getId());

        // --- STATE 2: MANAGED ---
        // persist() hands the object to the persistence context.
        // JPA now tracks every field change on 'newProduct'.
        em.persist(newProduct);
        System.out.println("State: MANAGED — id assigned: " + newProduct.getId());

        // Dirty checking in action: we change a field WITHOUT calling any save method.
        // JPA will detect this change and generate an UPDATE automatically on commit.
        newProduct.setPrice(129.99); // <-- no em.save(), no em.update() needed
        System.out.println("Price changed to 129.99 — JPA will auto-flush this on commit");

        em.getTransaction().commit(); // Flush happens here — INSERT then UPDATE sent to DB

        // --- STATE 3: DETACHED ---
        // After the transaction commits, the entity is still in memory
        // but JPA is no longer tracking it.
        em.close(); // closing the EntityManager detaches all entities
        System.out.println("State: DETACHED — object still in memory, but JPA ignores changes");

        // Changing a detached entity does NOT touch the database
        newProduct.setPrice(99.99); // silently ignored by JPA
        System.out.println("Price changed to 99.99 — but the DB still shows 129.99!");

        // To persist changes on a detached entity, you must merge() it
        // in a new EntityManager session:
        EntityManager em2 = emFactory.createEntityManager();
        em2.getTransaction().begin();
        Product reattached = em2.merge(newProduct); // now JPA tracks changes again
        em2.getTransaction().commit();
        System.out.println("After merge and commit — DB now shows: " + reattached.getPrice());

        em2.close();
        emFactory.close();
    }
}
Output
State: TRANSIENT — id is null: null
State: MANAGED — id assigned: 1
Price changed to 129.99 — JPA will auto-flush this on commit
State: DETACHED — object still in memory, but JPA ignores changes
Price changed to 99.99 — but the DB still shows 129.99!
After merge and commit — DB now shows: 99.99
Watch Out: Silent Updates
In a @Transactional Spring method, loading an entity and modifying it will ALWAYS generate an UPDATE on commit — even if you never call save(). This surprises developers who add a logging field or increment a counter inside a read-only method. Annotate genuinely read-only methods with @Transactional(readOnly = true) — Hibernate will skip dirty checking entirely, improving both correctness and performance.
Production Insight
Silent updates from dirty checking caused a production outage when a background job incremented a 'version' field on every entity it touched.
Read-only methods must be marked @Transactional(readOnly=true) to prevent accidental changes from flushing.
Rule: if you're not writing data, disable dirty checking explicitly.
Key Takeaway
The persistence context is the core of JPA.
Managed entities auto-track changes via dirty checking.
Always know your entity state: transient, managed, detached, or removed.

Mapping Real-World Relationships: @OneToMany, @ManyToOne, and the Ownership Rule

Relationships are where JPA gets genuinely powerful — and genuinely tricky. Let's use a real domain: an e-commerce order system. An Order has many OrderItems. An OrderItem belongs to one Order. That's a classic bidirectional @OneToMany / @ManyToOne.

The single most important concept here is the 'owning side'. In a bidirectional relationship, exactly one side must be the owner. The owner is the side that holds the foreign key column in the database. In a One-To-Many, the 'many' side (@ManyToOne) is ALWAYS the owner. This matters because JPA only looks at the owning side to decide what to write to the database. If you only update the 'mappedBy' side (the @OneToMany list) without setting the @ManyToOne reference, JPA writes nothing. This is one of the most common bugs in JPA code.

Fetch strategy is the other critical decision. @OneToMany defaults to LAZY loading — the list of items isn't fetched until you access it. @ManyToOne defaults to EAGER — the parent Order is fetched immediately. Changing these defaults without understanding the impact causes either N+1 query problems (too many small queries) or Cartesian product problems (one massive query that multiplies rows).

The golden rule: model relationships on both sides for object graph consistency, always set both sides in a helper method, and let the owning side drive persistence.

OrderEntityRelationship.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;

// ─── Order.java ───────────────────────────────────────────────
@Entity
@Table(name = "orders") // 'order' is a reserved SQL keyword — always quote or rename
public class Order {

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

    @Column(nullable = false)
    private String customerEmail;

    @Column(nullable = false)
    private LocalDateTime placedAt;

    // mappedBy = "order" means: "the 'order' field on OrderItem owns this relationship."
    // cascade = PERSIST, MERGE: saving/updating an Order auto-saves its items.
    // orphanRemoval = true: removing an item from this list deletes it from the DB.
    @OneToMany(
        mappedBy = "order",
        cascade = {CascadeType.PERSIST, CascadeType.MERGE},
        orphanRemoval = true,
        fetch = FetchType.LAZY  // default — explicitly written here for clarity
    )
    private List<OrderItem> items = new ArrayList<>();

    // ── Helper method to keep BOTH sides of the relationship in sync ──
    // This is the pattern senior devs use. Never call items.add() directly.
    public void addItem(OrderItem item) {
        items.add(item);       // update the 'one' side (in-memory list)
        item.setOrder(this);   // update the 'many' side (the foreign key owner)
    }

    public void removeItem(OrderItem item) {
        items.remove(item);
        item.setOrder(null);   // orphanRemoval will delete it from the DB
    }

    // Read-only view — prevents callers from bypassing addItem()
    public List<OrderItem> getItems() {
        return Collections.unmodifiableList(items);
    }

    // getters / setters
    public Long getId() { return id; }
    public String getCustomerEmail() { return customerEmail; }
    public void setCustomerEmail(String customerEmail) { this.customerEmail = customerEmail; }
    public LocalDateTime getPlacedAt() { return placedAt; }
    public void setPlacedAt(LocalDateTime placedAt) { this.placedAt = placedAt; }
}

// ─── OrderItem.java ───────────────────────────────────────────
@Entity
@Table(name = "order_items")
public class OrderItem {

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

    // @ManyToOne is the OWNING side — it holds the foreign key column 'order_id'
    // EAGER is the default for @ManyToOne, shown explicitly here
    @ManyToOne(fetch = FetchType.LAZY) // Override to LAZY to avoid unnecessary joins
    @JoinColumn(name = "order_id", nullable = false) // defines the FK column name
    private Order order;

    @Column(nullable = false)
    private String productSku;

    @Column(nullable = false, precision = 10, scale = 2)
    private BigDecimal unitPrice;

    @Column(nullable = false)
    private int quantity;

    // getters / setters
    public Long getId() { return id; }
    public Order getOrder() { return order; }
    public void setOrder(Order order) { this.order = order; }
    public String getProductSku() { return productSku; }
    public void setProductSku(String productSku) { this.productSku = productSku; }
    public BigDecimal getUnitPrice() { return unitPrice; }
    public void setUnitPrice(BigDecimal unitPrice) { this.unitPrice = unitPrice; }
    public int getQuantity() { return quantity; }
    public void setQuantity(int quantity) { this.quantity = quantity; }
}

// ─── Usage example (inside a @Transactional service) ──────────
public class OrderService {

    private final EntityManager em;

    public OrderService(EntityManager em) {
        this.em = em;
    }

    public Order createOrder(String customerEmail) {
        Order order = new Order();
        order.setCustomerEmail(customerEmail);
        order.setPlacedAt(LocalDateTime.now());

        OrderItem keyboard = new OrderItem();
        keyboard.setProductSku("KB-MX-RED");
        keyboard.setUnitPrice(new BigDecimal("149.99"));
        keyboard.setQuantity(1);

        OrderItem mousepad = new OrderItem();
        mousepad.setProductSku("MP-XL-BLK");
        mousepad.setUnitPrice(new BigDecimal("29.99"));
        mousepad.setQuantity(2);

        // Using the helper method — both sides stay consistent
        order.addItem(keyboard);
        order.addItem(mousepad);

        // cascade PERSIST means JPA will also INSERT both OrderItems
        em.persist(order);

        System.out.println("Order created with id: " + order.getId());
        System.out.println("Items count: " + order.getItems().size());
        return order;
    }
}
Output
Hibernate: insert into orders (customer_email, placed_at) values (?, ?)
Hibernate: insert into order_items (order_id, product_sku, quantity, unit_price) values (?, ?, ?, ?)
Hibernate: insert into order_items (order_id, product_sku, quantity, unit_price) values (?, ?, ?, ?)
Order created with id: 1
Items count: 2
Pro Tip: Always Override equals() and hashCode() on Entities
Use the database ID for equals/hashCode, but guard against null IDs (transient state). The safest pattern: use instanceof checks and only compare by id if both ids are non-null, otherwise fall back to object identity. Using Lombok's @EqualsAndHashCode without thought will break Set-based collections when entities transition from transient to managed state — the hashCode changes as the id goes from null to a value.
Production Insight
A common production bug: adding items to an order without setting the back reference.
The foreign key column stays null because JPA only persists the owning side.
Fix: always use a helper method that calls item.setOrder(this) alongside items.add(item).
Key Takeaway
In bidirectional relationships, the @ManyToOne side owns the foreign key.
Always set both sides of the relationship.
A helper method is the only safe way to add child entities.

Querying with JPQL and the N+1 Problem You Must Know How to Spot

JPQL (Java Persistence Query Language) lets you write queries against your entity model instead of your database tables. That's the key difference from SQL — you write FROM Order o, not FROM orders o. JPA translates it. This means your queries stay valid even if you rename a column, as long as you update the entity mapping.

But the query you write isn't always the query JPA executes. This gap is where the infamous N+1 problem lives. It happens when you fetch a list of N entities (1 query), and then as you iterate and access a lazy collection on each one, JPA fires an additional query per entity (N queries). Fetch 50 orders and touch each order's items — you've just fired 51 database round trips instead of 1.

The fix is JOIN FETCH. It tells JPA to retrieve the parent and its collection in a single JOIN query. But JOIN FETCH has its own trap: if you join-fetch multiple collections at once, you get a Cartesian product in the result set. The safe pattern for multi-collection fetches is to use @EntityGraph or run separate queries.

For complex reporting queries where you don't need full entity hydration, use JPQL projections or constructor expressions — they fetch only the columns you need and skip the overhead of building full entity objects.

OrderRepository.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
import jakarta.persistence.*;
import java.util.List;

public class OrderRepository {

    private final EntityManager em;

    public OrderRepository(EntityManager em) {
        this.em = em;
    }

    // ─── PROBLEM: N+1 Query ───────────────────────────────────────
    // This loads all orders in 1 query.
    // But the moment we call order.getItems() in a loop, Hibernate fires
    // a separate SELECT for each order's items. 50 orders = 51 queries.
    public void demonstrateNPlusOne() {
        List<Order> orders = em.createQuery("SELECT o FROM Order o", Order.class)
                               .getResultList();

        // This loop is the trap — each getItems() call hits the database
        for (Order order : orders) {
            System.out.println(order.getCustomerEmail()
                + " — items: " + order.getItems().size()); // N queries fired here
        }
    }

    // ─── SOLUTION 1: JOIN FETCH ────────────────────────────────────
    // Fetches orders AND their items in a single SQL JOIN.
    // Use DISTINCT to prevent duplicate Order objects from the join result set.
    public List<Order> findAllOrdersWithItems() {
        return em.createQuery(
            "SELECT DISTINCT o FROM Order o JOIN FETCH o.items",
            Order.class
        ).getResultList();
        // Generated SQL: SELECT DISTINCT o.*, oi.* FROM orders o
        //                INNER JOIN order_items oi ON oi.order_id = o.id
    }

    // ─── SOLUTION 2: @EntityGraph (Spring Data JPA style) ─────────
    // Cleaner API — define the graph on the entity or inline.
    // Shown here as a named query for clarity.
    public List<Order> findOrdersWithItemsViaEntityGraph() {
        EntityGraph<Order> graph = em.createEntityGraph(Order.class);
        graph.addAttributeNodes("items"); // tell JPA to eagerly load 'items'

        return em.createQuery("SELECT o FROM Order o", Order.class)
                 .setHint("jakarta.persistence.fetchgraph", graph)
                 .getResultList();
    }

    // ─── JPQL Projection: fetch only what you need ─────────────────
    // For a summary dashboard, you don't need full Order objects.
    // A DTO projection is faster — no entity tracking overhead.
    public List<OrderSummary> findOrderSummaries() {
        return em.createQuery(
            // Constructor expression — JPA calls new OrderSummary(email, count, total)
            "SELECT new com.example.OrderSummary(o.customerEmail, COUNT(i), SUM(i.unitPrice * i.quantity)) "
          + "FROM Order o JOIN o.items i "
          + "GROUP BY o.customerEmail",
            OrderSummary.class
        ).getResultList();
    }

    // ─── Named Query (defined on the entity with @NamedQuery) ───────
    // Validated at startup — typos fail fast, not at runtime.
    // On Order entity: @NamedQuery(name="Order.findByEmail",
    //                              query="SELECT o FROM Order o WHERE o.customerEmail = :email")
    public List<Order> findByCustomerEmail(String email) {
        return em.createNamedQuery("Order.findByEmail", Order.class)
                 .setParameter("email", email) // always use named params — prevents SQL injection
                 .getResultList();
    }
}

// ─── DTO for projection queries ────────────────────────────────
class OrderSummary {
    private final String customerEmail;
    private final long itemCount;
    private final java.math.BigDecimal totalValue;

    // JPA calls this constructor via the JPQL constructor expression
    public OrderSummary(String customerEmail, long itemCount, java.math.BigDecimal totalValue) {
        this.customerEmail = customerEmail;
        this.itemCount = itemCount;
        this.totalValue = totalValue;
    }

    @Override
    public String toString() {
        return customerEmail + " | Items: " + itemCount + " | Total: $" + totalValue;
    }
}
Output
// demonstrateNPlusOne() with 3 orders — Hibernate log shows:
Hibernate: select o1_0.id, o1_0.customer_email, o1_0.placed_at from orders o1_0
Hibernate: select items0_.order_id ... from order_items where order_id=1
Hibernate: select items0_.order_id ... from order_items where order_id=2
Hibernate: select items0_.order_id ... from order_items where order_id=3
// findAllOrdersWithItems() — single query:
Hibernate: select distinct o1_0.id, o1_0.customer_email, o1_0.placed_at,
i1_0.order_id, i1_0.id, i1_0.product_sku, i1_0.quantity, i1_0.unit_price
from orders o1_0 join order_items i1_0 on i1_0.order_id=o1_0.id
// findOrderSummaries() output:
jane@example.com | Items: 2 | Total: $209.97
bob@example.com | Items: 1 | Total: $29.99
Interview Gold: N+1 Is Always About Lazy Loading in a Loop
When interviewers ask about JPA performance, N+1 is the answer they're fishing for 80% of the time. Know how to spot it (enable Hibernate SQL logging with spring.jpa.show-sql=true and count the SELECT statements), and know the three fixes: JOIN FETCH for single collections, @EntityGraph for flexibility, and separate queries or batch fetching (hibernate.default_batch_fetch_size=25) for multiple collections.
Production Insight
N+1 queries are the #1 JPA performance killer in production.
Always enable SQL logging in dev to see real query count.
If you see 1 + N queries, apply JOIN FETCH or @EntityGraph immediately.
Key Takeaway
N+1 happens when you access a lazy collection in a loop.
Count SQL statements to detect it.
Fix with JOIN FETCH (single collection) or @EntityGraph (multiple).

Caching: First-Level and Second-Level

JPA defines two caching layers. The first-level cache is tied to the EntityManager (persistence context). Every entity you load or persist within a transaction is stored in this cache. Subsequent lookups of the same entity by primary key within the same transaction avoid a database round trip. This cache is always enabled and you can't disable it.

The second-level cache is optional and shared across EntityManager instances. When enabled, entities loaded in one session are cached so the next session can retrieve them without hitting the database. This is useful for reference data that rarely changes (country codes, product categories). The second-level cache must be explicitly configured and is typically backed by a distributed cache like Redis or Hazelcast.

A common mistake is assuming the second-level cache will work without configuring the cache provider and without enabling cacheable on entities. Even if you add @Cacheable, Hibernate requires a cache region configuration. Without it, the annotation is silently ignored.

Cache invalidation is another trap. When you update an entity directly via SQL or via another application, the second-level cache becomes stale. Use a cache TTL or trigger a manual eviction using the EntityManagerFactory cache API.

SecondLevelCacheConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
// ─── Step 1: Add Hibernate caching dependencies (Maven) ───────
// <dependency>
//     <groupId>org.hibernate.orm</groupId>
//     <artifactId>hibernate-jcache</artifactId>
// </dependency>
// <dependency>
//     <groupId>org.ehcache</groupId>
//     <artifactId>ehcache</artifactId>
//     <classifier>jakarta</classifier>
// </dependency>

// ─── Step 2: Configure in application.properties ──────────────
// spring.jpa.properties.hibernate.cache.use_second_level_cache=true
// spring.jpa.properties.hibernate.cache.region.factory_class=org.hibernate.cache.jcache.internal.JCacheRegionFactory
// spring.jpa.properties.javax.cache.provider=org.ehcache.jsr107.EhcacheCachingProvider

// ─── Step 3: Enable caching on an entity ───────────────────────
import jakarta.persistence.*;
import org.hibernate.annotations.Cache;
import org.hibernate.annotations.CacheConcurrencyStrategy;

@Entity
@Cacheable
@Cache(usage = CacheConcurrencyStrategy.READ_ONLY) // for reference data
public class ProductCategory {

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

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

    @Column(nullable = false)
    private String displayName;

    // getters and setters
}

// ─── Step 4: Manual cache eviction ─────────────────────────────
@Service
public class CacheService {

    public void evictAllSecondLevelCache(EntityManagerFactory emf) {
        emf.getCache().evictAll();
    }

    public void evictRegion(String regionName) {
        // Region name is typically the fully qualified entity class name
        emf.getCache().evict(regionName);
    }
}
Output
// After enabling second-level cache, the following log shows cache hit:
Hibernate: select pc1_0.id,pc1_0.code,pc1_0.display_name from product_category pc1_0 where pc1_0.code=?
// Second query for same category: no SQL log — served from cache
Hibernate: <!-- no SQL emitted -->
Cache Strategy Selection
Use READ_ONLY for immutable reference data, READ_WRITE for frequently modified but not critical data (with distributed locks), and TRANSACTIONAL for strict consistency (requires JTA). NONSTRICT_READ_WRITE allows some stale reads but offers better performance for data that tolerates eventual consistency.
Production Insight
Second-level cache can mask database performance issues and cause stale data bugs.
Always configure a TTL for cache regions to force periodic refreshes.
For distributed systems, use a replicated or distributed cache (Redis, Hazelcast) to avoid stale reads across nodes.
Key Takeaway
First-level cache is per-session and always on.
Second-level cache requires explicit configuration and a cache provider.
Use READ_ONLY for reference data; evict on updates.

Transaction Management and Isolation Levels

JPA transactions are managed through the EntityTransaction API or declaratively via @Transactional. In Spring, @Transactional opens a transaction before the method starts and commits (or rolls back) after it returns. The propagation and isolation level behaviours are defined on this annotation.

The isolation level determines how transactions interact. The default (READ_COMMITTED) prevents dirty reads but allows non-repeatable reads and phantom reads. REPEATABLE_READ prevents those but can cause more deadlocks. SERIALIZABLE is the safest but has the worst concurrency. Choosing the wrong isolation level leads to data consistency bugs that are hard to reproduce.

Another important concept is transaction propagation. REQUIRED (default) joins an existing transaction or creates a new one. REQUIRES_NEW suspends the current transaction and creates a new one — useful for audit logging where you want to commit independently. NESTED uses savepoints (if supported) to allow partial rollbacks.

A common pitfall is calling a @Transactional method from within the same class. Spring's AOP proxies won't intercept internal calls, so the transaction settings are ignored. The method will run without any transaction boundary.

TransactionConfig.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import org.springframework.transaction.annotation.Transactional;
import org.springframework.transaction.annotation.Isolation;
import org.springframework.transaction.annotation.Propagation;

@Service
public class OrderService {

    // ─── Basic Read-Only Transaction ─────────────────────────────
    @Transactional(readOnly = true)
    public List<Order> findAllOrders() {
        // No dirty checking, no unnecessary UPDATEs
        return em.createQuery("SELECT o FROM Order o", Order.class).getResultList();
    }

    // ─── Transaction with Custom Isolation ────────────────────────
    @Transactional(isolation = Isolation.REPEATABLE_READ)
    public Order updateOrderStatus(Long orderId, String status) {
        Order order = em.find(Order.class, orderId);
        order.setStatus(status);
        // Flush and commit happen automatically on method exit
        return order;
    }

    // ─── REQUIRES_NEW for Independent Audit ───────────────────────
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void logAudit(String action, Long entityId) {
        // This runs in its own transaction; rolls back independently
        AuditLog log = new AuditLog();
        log.setAction(action);
        log.setEntityId(entityId);
        em.persist(log);
    }

    // ─── Pitfall: Self-Invocation ─────────────────────────────────
    public void selfInvocationProblem() {
        // This call does NOT apply @Transactional from updateOrderStatus
        // because it's called from within the same class.
        updateOrderStatus(1L, "SHIPPED");
    }
}
Output
// No output — but demonstrates the self-invocation issue:
// Calling updateOrderStatus() within OrderService bypasses the AOP proxy.
// Transactional annotations on self-invoked methods are ignored.
// Fix: inject a separate service bean or use AspectJ weaving.
Self-Invocation Trap
Never call a @Transactional method from another method in the same class. Spring's proxy won't intercept it. Move transactional logic to a separate @Service bean and inject it. Otherwise your transaction boundaries are silently ignored — leading to data corruption that's hard to track down.
Production Insight
A production incident where an audit log was never persisted because the calling method lacked @Transactional.
The audit method used REQUIRES_NEW but was called from a loop inside the same service.
Fix: extract audit logic into its own service bean.
Key Takeaway
Transaction isolation prevents concurrency anomalies.
Propagation controls transaction boundaries.
Self-invocation of @Transactional methods breaks AOP — extract to a separate bean.
● Production incidentPOST-MORTEMseverity: high

N+1 Queries Caused a 30x Database Load Spike

Symptom
An order listing API that returned 100 orders took 12 seconds to respond. Database CPU hit 100%. The endpoint previously responded in 20ms.
Assumption
The team assumed that accessing order.getItems() inside a loop would fetch data in a single query because the relationship was annotated with @OneToMany.
Root cause
The @OneToMany relationship used default FetchType.LAZY. Each call to order.getItems() inside the loop triggered a separate SELECT statement. With 100 orders, this produced 1 SQL query for the list + 100 individual queries for items = 101 queries. But the team also had a status history collection with default fetching, resulting in another 100 queries per order, totalling 1,001 queries.
Fix
Replaced the loop-based iteration with a JPQL query using JOIN FETCH on both collections. Added @EntityGraph for the second collection to avoid Cartesian product. Set hibernate.default_batch_fetch_size=25 as a safety net for other lazy collections. Response time dropped back to 25ms.
Key lesson
  • Always enable SQL logging (spring.jpa.show-sql=true) during development to see the actual number of queries.
  • Never assume that a lazy collection will be efficient — always verify with logs or a profiler.
  • JOIN FETCH works for one collection; for multiple collections, use @EntityGraph or separate queries with batch fetching.
Production debug guideQuick reference for diagnosing JPA issues in production4 entries
Symptom · 01
LazyInitializationException thrown when accessing a collection or lazily loaded attribute
Fix
Ensure the access happens inside an active @Transactional context. If outside, use JOIN FETCH in the query to load the data eagerly, or call Hibernate.initialize() before closing the session.
Symptom · 02
Unexpected UPDATE statement on commit even though no save() was called
Fix
This is dirty checking. If the method is supposed to be read-only, annotate it with @Transactional(readOnly = true) to disable dirty checking. If a field is changed accidentally, inspect the entity's setters and any detach/merge logic.
Symptom · 03
Foreign key column is null after persisting a child entity in a bidirectional relationship
Fix
Check that the owning side (@ManyToOne) was set. You likely only updated the @OneToMany side. Always use a helper method that sets both sides of the relationship.
Symptom · 04
Duplicate entities in the result set after using JOIN FETCH
Fix
Add DISTINCT to the JPQL query. Without DISTINCT, the SQL join duplicates parent rows for each child. JPA may deduplicate in memory if you use DISTINCT, but it's better to use DISTINCT in the query.
★ Quick Debug Cheat Sheet for JPA IssuesCommands and configurations to diagnose JPA behaviour fast
Too many SQL queries — suspect N+1
Immediate action
Enable SQL logging in application.properties
Commands
spring.jpa.show-sql=true
spring.jpa.properties.hibernate.format_sql=true
Fix now
Count the SELECT statements in the log. Each extra SELECT per entity confirms N+1. Apply JOIN FETCH or @EntityGraph in the data access layer.
LazyInitializationException+
Immediate action
Check if the code is accessing a lazy property outside a @Transactional boundary
Commands
Wrap the calling method with @Transactional
If you cannot keep the transaction open, use JOIN FETCH in the query or call Hibernate.initialize(entity.getCollection()) within the transaction.
Fix now
Move the collection access inside the service method that was already annotated with @Transactional, or refactor to use a DTO projection that avoids lazy loading.
Entity updates are not persisted+
Immediate action
Check if the entity is in detached state
Commands
Call entityManager.merge(entity) to reattach it
Alternatively, load the entity again within the transaction using find() and modify it there.
Fix now
Redesign the workflow to keep the entity managed for the duration of the transaction, or use a DTO-based approach that reattaches on merge.
JPA Query Approaches Comparison
AspectJPQL (JPA Standard)Criteria APINative SQL
Syntax styleString-based, entity-awareType-safe Java builder APIRaw SQL strings
SQL injection safetySafe with named params (:param)Fully safe by designRisky — requires careful escaping
Compile-time checkingNone — fails at runtimeYes — with JPA MetamodelNone
ReadabilityHigh for simple queriesVerbose for complex queriesHigh for SQL experts
Dynamic query buildingPainful — string concatenationExcellent — built for thisPossible but messy
Portability across DBsYes — JPA translatesYes — JPA translatesNo — vendor-specific SQL
Best forStatic, readable queriesSearch/filter forms with optional criteriaPerformance-critical bulk ops or stored procedures

Key takeaways

1
The persistence context is JPA's beating heart
every managed entity is auto-tracked for changes via dirty checking, so unexpected UPDATEs are always a state management issue, not a bug in your save logic.
2
In any bidirectional relationship, the @ManyToOne side owns the foreign key
always set it, always use a helper method on the parent to keep both sides consistent.
3
N+1 is caused by accessing a LAZY collection in a loop
detect it by counting SQL statements in logs, fix it with JOIN FETCH or @EntityGraph, and prefer batch fetch size for multiple collections.
4
Use JPQL for readable static queries, Criteria API for dynamic filter queries, and native SQL only when you need database-specific features or bulk performance that JPA can't optimise
and document exactly why.
5
Second-level cache is optional and requires explicit setup
never assume @Cacheable is enough without a provider and region configuration.
6
Transaction isolation and propagation are not just theoretical
choose them carefully based on concurrency requirements, and avoid self-invocation of @Transactional methods.

Common mistakes to avoid

5 patterns
×

Only updating the 'mappedBy' side of a bidirectional relationship

Symptom
You call order.getItems().add(item) but the foreign key column in order_items is never populated; the item is saved with order_id = null or silently ignored.
Fix
Always use a helper method that sets both sides: order.addItem(item) which internally does items.add(item) AND item.setOrder(order). The owning side (@ManyToOne) must always be set for JPA to write the foreign key.
×

Using CascadeType.ALL on @ManyToOne

Symptom
Deleting an OrderItem accidentally deletes its parent Order, cascading deletes up the relationship tree and wiping data.
Fix
CascadeType.ALL (which includes REMOVE) should almost never go on @ManyToOne. It belongs on @OneToMany from parent to children. Think of cascade as 'parent controls children', not 'child controls parent'.
×

Calling entity getters on a LAZY collection outside a transaction

Symptom
LazyInitializationException: could not initialize proxy — no Session
Fix
Ensure the collection is accessed within an active @Transactional boundary, use JOIN FETCH to load it eagerly when you know you'll need it, or use a DTO projection instead. Never rely on the 'Open Session in View' anti-pattern in production — it hides N+1 problems and leaks database connections.
×

Assuming second-level cache works without configuration

Symptom
No performance improvement even after adding @Cacheable. Entities are always fetched from the database.
Fix
Add the Hibernate cache provider dependencies (e.g., hibernate-jcache + Ehcache), set spring.jpa.properties.hibernate.cache.use_second_level_cache=true, configure the region factory, and set the cache concurrency strategy on the entity.
×

Self-invoking @Transactional methods inside the same class

Symptom
Transaction settings (isolation, propagation, readOnly) are ignored. The method runs without any transaction boundary, causing inconsistent data or missing rollbacks.
Fix
Move transactional methods to a separate Spring bean and inject it. Never call @Transactional methods from another method in the same class.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the difference between persist(), merge(), and save() in JPA — a...
Q02SENIOR
Explain the N+1 query problem in JPA. How do you detect it in a running ...
Q03SENIOR
What is the difference between FetchType.LAZY and FetchType.EAGER, and w...
Q01 of 03SENIOR

What is the difference between persist(), merge(), and save() in JPA — and when would using the wrong one cause a bug?

ANSWER
persist() makes a transient entity managed and schedules an INSERT. merge() copies state from a detached entity to a managed entity (or creates a new one) and returns the managed instance. save() is not a JPA method; it's Hibernate-specific and is similar to persist but can return the generated ID. Using persist on a detached entity throws IllegalArgumentException. Using merge on a newly created entity that already exists in the DB can cause a duplicate if the ID is assigned. The rule: use persist for new entities you create, merge for reattaching detached entities you received from a client.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between JPA and Hibernate?
02
When should I use JPA instead of plain JDBC or jOOQ?
03
Why does JPA update my entity in the database even though I never called save()?
04
What is the difference between first-level and second-level cache?
05
What isolation level should I use for JPA transactions?
🔥

That's ORM. Mark it forged?

6 min read · try the examples if you haven't

Previous
Hibernate ORM Basics
3 / 7 · ORM
Next
Sequelize ORM for Node.js