11 JPA Performance Killers (And How to Fix Each One)

    11 JPA Performance Killers (And How to Fix Each One)

    01/05/2026

    The silent killers

    JPA makes database access easy. That's exactly why it gets dangerous in production. The abstraction hides SQL, flushes, fetch plans, and connection lifetime, then one endpoint that looked harmless in dev starts loading 200 orders in 8 seconds.

    These anti-patterns show up in Spring Data JPA apps again and again. They line up with the usual advice from Hibernate performance experts, but this version also calls out the Hibernate 6/7-era fixes that are worth knowing.


    Anti-pattern 1: Not verifying SQL during development

    The first performance bug is not a query. It's flying blind.

    Small dev databases hide bad fetch plans. A repository method that runs 2 queries locally might run 202 queries when the result set grows. Before tuning anything, make Hibernate show you what it is doing.

    spring.jpa.properties.hibernate.generate_statistics=true logging.level.org.hibernate.SQL=DEBUG logging.level.org.hibernate.orm.jdbc.bind=TRACE logging.level.org.hibernate.stat=DEBUG

    In Hibernate 6, org.hibernate.orm.jdbc.bind is the useful logger for bind values.


    Anti-pattern 2: The N+1 query problem

    This is the most common and the most impactful. You load a list of entities, then for each entity, JPA fires a separate query to load a related association. One query becomes N+1 queries.

    Here's what it looks like with a simple order list:

    @Entity public class Order { @Id Long id; @ManyToOne(fetch = FetchType.LAZY) private Customer customer; @OneToMany(mappedBy = "order") private List<LineItem> lineItems; // fetched separately per order } // In a service: List<Order> orders = orderRepository.findAll(); // Query 1: SELECT * FROM orders for (Order order : orders) { // Each access fires a query: SELECT * FROM line_items WHERE order_id = ? order.getLineItems().size(); // Query 2, 3, 4... up to N+1 }

    With 100 orders, that loop can hit the database 101 times.

    Fix 1: JOIN FETCH in JPQL

    @Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.lineItems WHERE o.status = :status") List<Order> findWithLineItems(@Param("status") String status);

    One query with a JOIN — all data loaded in one round trip.

    Fix 2: Entity Graph

    @EntityGraph(attributePaths = {"lineItems", "customer"}) List<Order> findByStatus(String status);

    Entity graphs let you define fetch strategy per query without modifying the mapping. Prefer this over changing fetch = FetchType.EAGER on the entity itself — eager on the entity always fetches eagerly, even when you don't need it.

    Fix 3: Batch fetching

    When JOIN FETCH isn't practical (pagination conflicts — more on that below), use batch fetching:

    import org.hibernate.annotations.BatchSize; @Entity public class Order { @OneToMany(mappedBy = "order") @BatchSize(size = 50) // Hibernate fetches 50 at a time instead of 1 private List<LineItem> lineItems; }

    Or globally in application.properties:

    spring.jpa.properties.hibernate.default_batch_fetch_size=50

    I usually start around 25 or 50. Very large batch sizes create huge IN (...) lists and can hurt the database plan.


    Anti-pattern 3: Mixing collection JOIN FETCH with pagination

    This one catches developers who fixed their N+1 problem but introduced a subtler bug.

    You add JOIN FETCH to load orders with their line items, then add pagination. Hibernate prints a warning you might have ignored:

    HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
    

    What's happening: Hibernate can't translate LIMIT 10 into SQL correctly when JOIN FETCH is involved — because a JOIN FETCH on a collection multiplies rows. An order with 5 line items becomes 5 rows. If you LIMIT 10 in SQL, you might get 2 complete orders and 0 others — not what you want.

    So Hibernate fetches ALL data into memory and then applies the limit in Java. For large tables, this is an OOM waiting to happen.

    // This looks correct but is dangerous: @Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.lineItems") Page<Order> findAll(Pageable pageable); // Warning: in-memory pagination

    Turn the warning into a failure in dev and test:

    spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true

    Fix 1: Two-query approach

    // Step 1: paginate IDs only (simple, fast, correct) @Query(value = "SELECT o.id FROM Order o", countQuery = "SELECT COUNT(o) FROM Order o") Page<Long> findOrderIds(Pageable pageable); // Step 2: load full entities for those IDs with JOIN FETCH @Query("SELECT DISTINCT o FROM Order o JOIN FETCH o.lineItems WHERE o.id IN :ids") List<Order> findWithLineItemsByIds(@Param("ids") List<Long> ids);

    The second query returns rows in database order, not necessarily the page order. Re-sort in the service if the order matters:

    List<Long> ids = page.getContent(); Map<Long, Order> byId = orders.stream() .collect(Collectors.toMap(Order::getId, Function.identity())); List<Order> sorted = ids.stream() .map(byId::get) .filter(Objects::nonNull) .toList();

    Fix 2: Hibernate 6+ window functions

    Hibernate 6 added stronger HQL support for window functions, so a top-N fetch can be expressed in one query:

    @Query(""" SELECT DISTINCT o FROM Order o LEFT JOIN FETCH o.lineItems WHERE o.id IN ( SELECT ranked.id FROM ( SELECT o2.id AS id, dense_rank() OVER (ORDER BY o2.createdAt DESC) AS ranking FROM Order o2 WHERE o2.status = :status ) ranked WHERE ranked.ranking <= :limit ) """) List<Order> findTopWithLineItems(@Param("status") String status, @Param("limit") int limit);

    Use this for top-N queries. For normal page N of M, the two-query pattern or keyset pagination is still easier to reason about.


    Anti-pattern 4: FetchType.EAGER and default eager to-one associations

    This is usually baked in from the beginning. Someone sets @OneToMany(fetch = FetchType.EAGER) to "avoid lazy loading issues", or leaves @ManyToOne and @OneToOne on their JPA defaults. Now every query loads more graph than the use case asked for.

    import jakarta.persistence.Entity; import jakarta.persistence.FetchType; import jakarta.persistence.ManyToOne; import jakarta.persistence.OneToMany; import java.util.List; import java.util.Optional; @Entity public class Customer { @OneToMany(fetch = FetchType.EAGER) // loaded on every customer fetch private List<Order> orders; @OneToMany(fetch = FetchType.EAGER) // also loaded on every customer fetch private List<Address> addresses; }

    Loading a customer for a simple display now fires three queries and returns potentially thousands of rows. If your customer report loops over 500 customers, that's 500 × 3 queries minimum.

    Defaults in JPA:

    • @ManyToOne / @OneToOne — EAGER by default
    • @OneToMany / @ManyToMany — LAZY by default

    Hibernate will also throw MultipleBagFetchException if you try to EAGER-load more than one collection simultaneously:

    org.hibernate.loader.MultipleBagFetchException: cannot simultaneously fetch multiple bags
    

    Fix: Keep associations lazy, fetch per use case

    @Entity public class Customer { @OneToMany(mappedBy = "customer", fetch = FetchType.LAZY) // explicit private List<Order> orders; } @Entity public class Order { @ManyToOne(fetch = FetchType.LAZY) private Customer customer; } // Load with items only when needed @EntityGraph(attributePaths = "orders") Optional<Customer> findWithOrdersById(Long id); // Simple load without items Optional<Customer> findById(Long id); // no orders loaded

    This gives you control per query instead of baking the fetch strategy into every load.


    Anti-pattern 5: Calling saveAndFlush() for every update

    Spring Data JPA's saveAndFlush() looks harmless. It is not.

    Inside a transaction, managed entities are dirty-checked and flushed automatically before commit. Calling saveAndFlush() in a loop forces Hibernate to flush the entire persistence context repeatedly.

    @Transactional public void renameCustomers(List<Long> ids) { for (Long id : ids) { Customer customer = customerRepository.findById(id).orElseThrow(); customer.rename("Archived"); customerRepository.saveAndFlush(customer); // forces a flush every iteration } }

    If 100 customers are managed, every flush checks the managed context. You also break Hibernate's ability to group similar JDBC statements efficiently.

    Fix: rely on dirty checking

    @Transactional public void renameCustomers(List<Long> ids) { List<Customer> customers = customerRepository.findAllById(ids); for (Customer customer : customers) { customer.rename("Archived"); } }

    Use flush() only when you need a database constraint to be checked before the transaction ends, or when a later query in the same transaction must see the changes immediately.


    Anti-pattern 6: Bad ID generation for write-heavy tables

    ID generation affects insert throughput. On databases with sequences, prefer SEQUENCE with an allocation size that matches your write pattern:

    import jakarta.persistence.Entity; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.SequenceGenerator; @Entity public class Payment { @Id @GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "payment_seq") @SequenceGenerator( name = "payment_seq", sequenceName = "payment_sequence", allocationSize = 50 ) private Long id; }

    Hibernate can reserve a block of IDs and avoid asking the database for every single insert. This works well with JDBC batching:

    spring.jpa.properties.hibernate.jdbc.batch_size=50 spring.jpa.properties.hibernate.order_inserts=true spring.jpa.properties.hibernate.order_updates=true

    GenerationType.IDENTITY is the right choice for databases that depend on auto-increment columns, but it usually prevents insert batching because Hibernate needs the generated key immediately after each insert. Avoid GenerationType.AUTO unless you have checked what your dialect actually chooses.


    Anti-pattern 7: Using entities or complex interface projections for read-only screens

    Fetching a managed entity is useful when you plan to modify it. For a read-only list page, it is often waste: Hibernate selects all mapped columns, creates entity instances, stores snapshots for dirty checking, and keeps them in the persistence context.

    This is worse with complex Spring Data interface projections that include nested associations or SpEL expressions. They can silently fall back to loading full entities and related rows.

    public interface OrderView { Long getId(); String getStatus(); CustomerView getCustomer(); // nested association can trigger extra fetching }

    Fix: use DTO projections for read models

    import java.math.BigDecimal; public record OrderSummary( Long id, String status, String customerEmail, BigDecimal total ) {} @Query(""" SELECT new com.example.OrderSummary(o.id, o.status, c.email, o.total) FROM Order o JOIN o.customer c WHERE o.status = :status ORDER BY o.createdAt DESC """) List<OrderSummary> findSummariesByStatus(@Param("status") String status);

    DTO projections select the columns you ask for and skip entity lifecycle overhead. Interface projections are fine for simple scalar fields, but I avoid nested interface projections on hot paths unless SQL logs prove they stay lean.


    Anti-pattern 8: Mapping many-to-many relationships as List

    This one is easy to miss because List feels like the default Java collection. In Hibernate, a many-to-many List without an order column is treated like a bag: unordered, duplicate-friendly, and hard to diff efficiently.

    @Entity public class Book { @ManyToMany private List<Author> authors = new ArrayList<>(); }

    Remove one author from that list and Hibernate may delete all rows for that book from the join table, then re-insert the remaining rows. You changed one relationship, but the SQL rewrites the whole collection.

    It also makes MultipleBagFetchException more likely when you try to fetch more than one bag in the same query.

    Fix: use Set for many-to-many

    @Entity public class Book { @ManyToMany private Set<Author> authors = new HashSet<>(); }

    Most many-to-many relationships should not contain duplicates anyway. If order matters, model it explicitly with an order column or, better, promote the join table to a real entity such as BookAuthor.


    Anti-pattern 9: Treating cache as a silver bullet

    Second-level cache and query cache can help, but they are not a general fix for bad fetch plans.

    The second-level cache helps most when you load entities by ID. It does not magically make every custom JPQL query free. Query cache is even more specific: it works best for mostly static data, because changes to the underlying tables invalidate cached query results.

    Hibernate version matters too. Hibernate 5 query cache stored entity identifiers, so a cached query could still trigger one select per entity if those entities were not in the second-level cache. Hibernate 6 changed the default query cache layout to store more complete row data, which avoids that old N+1 pattern but can make the cache much larger.

    Fix: cache only stable, measured hotspots

    @QueryHints(@QueryHint( name = "org.hibernate.cacheable", value = "true" )) @Query("SELECT c FROM Country c ORDER BY c.name") List<Country> findAllCached();

    Use cache for reference data, permission metadata, feature flags, or small lookup tables. For request-specific list screens, DTO projections and better SQL usually beat cache complexity.


    Anti-pattern 10: Missing indexes on columns used in WHERE, JOIN, and ORDER BY

    JPA generates the schema for you with ddl-auto=update or create, but it only creates indexes you explicitly declare. If you query by email, status, customerId, or any foreign key column without an index, every query does a full table scan.

    @Entity public class Order { private String status; // frequently filtered: WHERE status = ? private Long customerId; // JOIN/filter: WHERE customer_id = ? private LocalDate orderDate; // sorted: ORDER BY order_date DESC // No indexes declared — all queries above are full table scans }

    For a table with 10 rows this is invisible. For a table with 10 million rows, a missing index on status turns a millisecond query into a 30-second one.

    Fix: Declare indexes explicitly

    @Entity @Table(name = "orders", indexes = { @Index(name = "idx_orders_status", columnList = "status"), @Index(name = "idx_orders_customer_id", columnList = "customer_id"), @Index(name = "idx_orders_order_date", columnList = "order_date DESC"), @Index(name = "idx_orders_status_date", columnList = "status, order_date") // composite }) public class Order { // ... }

    Or use Flyway/Liquibase to manage indexes explicitly — which you should be doing anyway rather than relying on ddl-auto.

    -- V3__add_order_indexes.sql CREATE INDEX idx_orders_status ON orders(status); CREATE INDEX idx_orders_customer_id ON orders(customer_id); CREATE INDEX idx_orders_status_date ON orders(status, order_date DESC);

    Check slow queries: EXPLAIN ANALYZE in PostgreSQL or EXPLAIN in MySQL will show you if a query is doing a Seq Scan (full table) vs an Index Scan. Any query that appears in your logs with high execution time and no index hint is a candidate for optimization.

    📚 Related: Database Indexes Deep Dive — covers index types, composite indexes, and partial indexes.


    Anti-pattern 11: Open Session in View (OSIV)

    Spring Boot enables Open Session In View by default. What this means: the Hibernate EntityManager session stays open for the entire HTTP request lifecycle — through the controller, service, and view rendering layers.

    The intent was to prevent LazyInitializationException by keeping the session available when the view template accesses lazy associations. The practical effect: lazy loads can fire during JSON serialization or template rendering, outside the service transaction, often as extra auto-commit queries at the worst possible point in the request.

    Spring Boot logs a warning about this that most developers dismiss:

    spring.jpa.open-in-view is enabled by default. This property allows 
    Hibernate to use the current application context during request processing.
    This might cause performance and memory consumption issues.
    

    Why it's a problem at scale:

    Under high load, those late queries put pressure on the connection pool (HikariCP defaults to 10 connections) and make query ownership harder to reason about. The database work no longer belongs clearly to the service method that was supposed to define the transaction boundary.

    Fix: Disable OSIV and handle lazy loading explicitly

    spring.jpa.open-in-view=false

    This will surface LazyInitializationException in places where your code was accidentally relying on lazy loading outside the transaction. That's good — it makes the dependency explicit. Fix them with:

    // Option 1: JOIN FETCH / EntityGraph in repository @EntityGraph(attributePaths = {"lineItems", "customer"}) Optional<Order> findWithDetailsById(Long id); // Option 2: DTO projection — fetch only what you need @Query("SELECT new com.example.OrderSummary(o.id, o.status, o.total) FROM Order o WHERE o.id = :id") Optional<OrderSummary> findSummaryById(@Param("id") Long id); // Option 3: Load inside @Transactional service method @Transactional(readOnly = true) public OrderDetail getOrderDetail(Long id) { Order order = orderRepository.findById(id).orElseThrow(); Hibernate.initialize(order.getLineItems()); // explicit initialization return mapper.toDetail(order); }

    DTO projections are the most efficient — only the fields you need cross the wire from the database, and you're not carrying an entity graph through the stack.


    Correctness traps from the same talk

    Not everything in Thorben Janssen's Spring I/O 2025 talk is a pure performance issue. A few are worse: they can give you stale results or delete more data than intended.

    Bulk updates with @Modifying leave managed entities stale

    @Modifying(flushAutomatically = true, clearAutomatically = true) @Query("UPDATE Author a SET a.lastName = UPPER(a.lastName)") int uppercaseLastNames();

    Bulk JPQL updates and deletes go straight to the database. They do not update the managed entity instances already sitting in the persistence context. Use both flushAutomatically and clearAutomatically, or run the bulk operation in a clean transaction where stale managed entities are impossible.

    JOIN FETCH is for initialization, not filtering the fetched collection

    // Filters authors by book, but fetches the full books collection. @Query(""" SELECT DISTINCT a FROM Author a JOIN a.books b LEFT JOIN FETCH a.books WHERE b.id = :bookId """) List<Author> findAuthorsWithAllBooksForBook(@Param("bookId") Long bookId);

    If you use the same JOIN FETCH in the WHERE predicate, Hibernate initializes a partial collection and your entity graph no longer represents the database row set you think it does. Join once for filtering, join fetch separately for initialization.

    Bidirectional relationships need helper methods

    public void addBook(Book book) { books.add(book); book.getAuthors().add(this); } public void removeBook(Book book) { books.remove(book); book.getAuthors().remove(this); }

    Updating only the inverse side can result in no database change. Updating only the owning side can leave the current persistence context with stale in-memory state.

    Be careful with CascadeType.REMOVE

    Only cascade remove from a true parent to a child that cannot exist independently. Do not put CascadeType.REMOVE on both sides of a many-to-many relationship. That can turn "delete one author" into a chain of deletes across books, authors, and the join table.


    Putting it together

    JPA Performance — Anti-pattern vs Fix Anti-pattern Fix N+1: findAll() + lazy collection access JOIN FETCH / EntityGraph / @BatchSize JOIN FETCH + LIMIT → in-memory pagination Two-query: paginate IDs, then fetch by IDs FetchType.EAGER on collections Keep LAZY, use EntityGraph per query saveAndFlush() inside loops Use dirty checking, flush only when needed Bad ID generation on write-heavy tables SEQUENCE + allocationSize + JDBC batching Entity reads for list screens DTO projections for read-only views ManyToMany as List / Hibernate bag Use Set or model join table entity Cache as a generic performance fix Cache stable measured hotspots only Missing indexes on WHERE / JOIN / ORDER BY @Table(indexes=...) or Flyway migrations OSIV open: queries during rendering Disable OSIV, fetch explicit DTOs

    📚 Related: Spring Data JPA Cheat Sheet · Database Indexes Deep Dive · Offset vs Cursor Pagination

    🔗 Blog · LinkedIn · Medium · GitHub

    Discover Top YouTube Creators

    Explore Popular Tech YouTube Channels

    Find the most popular YouTube creators in tech categories like AI, Java, JavaScript, Python, .NET, and developer conferences. Perfect for learning, inspiration, and staying updated with the best tech content.

    Summarise

    Transform Your Learning

    Get instant AI-powered summaries of YouTube videos and websites. Save time while enhancing your learning experience.

    Instant video summaries
    Smart insights extraction
    Channel tracking