An endpoint takes 80 milliseconds on your laptop. In production, for your largest customer, it takes nine seconds and occasionally times out. Nothing changed in the code. What changed is the amount of data — and that pattern almost always points to the same culprit: the N+1 query problem.

At Riverstream Consultancy Services I tracked down and fixed 20+ slow SQL queries and N+1 patterns using Hibernate profiling and MySQL EXPLAIN plans. Average API response time improved by 40%, peak-hour database CPU dropped by around 35%, and client production tickets fell by half. This is the playbook I used.

20+
Slow queries &
N+1 patterns fixed
40%
Faster average
API response
~35%
Lower peak-hour
DB CPU
Think of it like this
You need 50 things from the supermarket. N+1 is driving to the shop, buying one item, driving home, and repeating that 50 times. Each trip is quick. Fifty trips is your whole evening. The fix is obvious once you see it: one trip, one list.

What N+1 looks like in Spring Data JPA

Here is a perfectly normal-looking entity and service. Relationships are lazy — as they should be — and the service maps entities to a summary DTO.

Order.java
@Entity @Table(name = "orders") public class Order { @Id @GeneratedValue private Long id; @ManyToOne(fetch = FetchType.LAZY) private Customer customer; @OneToMany(mappedBy = "order") private List<OrderItem> items = new ArrayList<>(); }
OrderService.java — the slow version
@Transactional(readOnly = true) public List<OrderSummary> summaries() { return orderRepository.findAll().stream() .map(o -> new OrderSummary( o.getId(), o.getCustomer().getName(), // 1 query per order o.getItems().size())) // another query per order .toList(); }

For 100 orders this runs one query to load the orders, then one per order for the customer and one per order for the items: 201 queries. For 10,000 orders it's 20,001. The code didn't get slower. The data got bigger — which is exactly why this bug shows up for your biggest customer first.

How to spot it before production does

Turn on SQL logging and Hibernate statistics in development. The tell-tale sign is the same SELECT repeated with a different ID each time.

application-dev.properties
# Show every SQL statement Hibernate sends (dev only!) logging.level.org.hibernate.SQL=DEBUG # Log per-session metrics, including how many JDBC statements ran spring.jpa.properties.hibernate.generate_statistics=true

Better still, make it impossible to reintroduce. An integration test can assert on the number of statements a service method executes:

OrderServiceIT.java
@Test void summariesDoNotTriggerNPlusOne() { Statistics stats = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); stats.clear(); orderService.summaries(); assertThat(stats.getPrepareStatementCount()).isLessThanOrEqualTo(2); }

Fix 1: JOIN FETCH when you need the entities

Tell Hibernate to load the associations in the same query. Since Hibernate 6, duplicate root entities from a collection join are removed automatically, so you no longer need DISTINCT for that purpose.

OrderRepository.java
@Query("select o from Order o join fetch o.customer left join fetch o.items") List<Order> findAllWithCustomerAndItems();

Fix 2: @EntityGraph for derived queries

If you prefer Spring Data's derived query methods, an entity graph does the same job without writing JPQL:

OrderRepository.java
@EntityGraph(attributePaths = {"customer", "items"}) List<Order> findByStatus(OrderStatus status);

Fix 3: batch fetching as a safety net

Batch fetching doesn't remove the extra queries, but it collapses them: instead of one query per order, Hibernate loads associations for many orders at once using an IN (…) clause. It's a good global default, and it's the right tool when you page over a collection.

application.properties
# Load lazy associations in groups of up to 50 IDs per query spring.jpa.properties.hibernate.default_batch_fetch_size=50

Fix 4: a DTO projection when you only need a few columns

The summary endpoint doesn't need full entities at all. A projection query returns exactly the columns you need, in a single round-trip, with no entities to manage:

OrderRepository.java — projection
public record OrderSummary(Long id, String customerName, long itemCount) {} @Query(""" select new com.example.orders.OrderSummary(o.id, c.name, count(i)) from Order o join o.customer c left join o.items i group by o.id, c.name """) List<OrderSummary> findSummaries();
Three traps that catch experienced developers
JOIN FETCH plus pagination: fetching a collection while using Pageable makes Hibernate load everything and paginate in memory, logging only a warning. Set spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true so it fails loudly instead, and use batch fetching for paged lists. Two List collections in one fetch: Hibernate throws MultipleBagFetchException; fetch one collection and batch-load the other, or model one as a Set. Open Session in View: Spring Boot enables spring.jpa.open-in-view by default, which lets lazy loading happen silently during JSON serialization. Set it to false so N+1 surfaces as an error in development instead of a slowdown in production.

Don't forget the database itself

Fixing the query count is half the job. Run EXPLAIN on the remaining queries and make sure foreign keys used in joins — like order_items.order_id — are indexed. A single query doing a full table scan can be slower than the hundred it replaced.

From the support side, N+1 has a signature: it works for every customer except the biggest one, and it gets worse every month without a single deploy.
— What I look for when a "random slowness" ticket comes in
N+1 = one query for the list, one more per row for each lazy association you touch.
It scales with data size, not traffic — so it hides in dev and hits your largest customers first.
Detect it with SQL logging, Hibernate statistics, and a statement-count assertion in your tests.
Fix it with JOIN FETCH, @EntityGraph, batch fetching, or a DTO projection — pick based on what the endpoint really needs.
Turn off open-in-view and index your join columns.