I was debugging a Spring Boot API at 2 AM on a Tuesday when I realized something that should have been obvious: my database query was being executed 500 times per request.
Not 5 times. Not 50 times.
Five. Hundred. Times.
One single user request → 500 SQL queries → API response time: 3.2 seconds.
That's when I learned about the N+1 query problem. And how it nearly destroyed production.
The Setup: Everything Seemed Fine
Our food delivery microservice was working great. Orders were being processed, customers were happy, and performance looked good in development.
Then we hit production traffic.
Day 1: Latency: 200ms ✅ Day 5: Latency: 500ms ⚠️ Day 10: Latency: 1.2 seconds 🔴 Day 15: Latency: 3+ seconds 💥
Our API was getting slower every single day. And we had no idea why.
I grabbed a profiler and started investigating. That's when I found it: the N+1 query catastrophe.
What Is the N+1 Query Problem?
Imagine you want to fetch a list of orders with their customers.
The naive approach:
@entity
public class Order {
@id
private Long id;
private String orderNumber;
@ManyToOne
private Customer customer; // This relationship is the problem
}
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
public List<Order> getAllOrders() {
return orderRepository.findAll(); // Query 1: Get all orders
// For each order, fetch customer // Query 2, 3, 4, 5...
}
}
Here's what happens in your database:
-- Query 1: Get all orders (1 query)
SELECT * FROM orders;
-- Query 2: Get customer for order 1 (N queries)
SELECT * FROM customers WHERE id = 101;
-- Query 3: Get customer for order 2
SELECT * FROM customers WHERE id = 102;
-- Query 4: Get customer for order 3
SELECT * FROM customers WHERE id = 103;
-- ... repeat for every single order ...
If you fetch 500 orders:
1 query to get orders
500 queries to get each customer
Total: 501 queries
That's the N+1 problem. You execute 1 query, then N more queries (one per result).
At scale, this destroys performance.
How I Discovered It (The Hard Way)
I was looking at our order endpoint logs:
GET /api/v1/orders
Database queries: 487
Query time: 2.8 seconds
487 queries for a single API request.
I added Spring Boot's query logging to see what was happening:
application.properties
spring.jpa.properties.hibernate.format_sql=true
spring.jpa.properties.hibernate.use_sql_comments=true
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE
Then I made a single API call. Here's what the logs showed:
Hibernate: select order0_.id, order0_.customer_id, order0_.order_number, order0_.total_amount from orders order0_ limit 100
Hibernate: select customer0_.id, customer0_.name, customer0_.email from customers customer0_ where customer0_.id=?
Hibernate: select customer0_.id, customer0_.name, customer0_.email from customers customer0_ where customer0_.id=?
Hibernate: select customer0_.id, customer0_.name, customer0_.email from customers customer0_ where customer0_.id=?
Hibernate: select customer0_.id, customer0_.name, customer0_.email from customers customer0_ where customer0_.id=?
... (repeat 95 more times)
My jaw dropped.
Hibernate was fetching the customer for every single order, one at a time. It was supposed to be doing one fetch. Instead, it was doing 100+ queries.
This is classic N+1 query problem.
The Root Cause: Lazy Loading
By default, JPA uses lazy loading for relationships:
@ManyToOne(fetch = FetchType.LAZY) // Default behavior
private Customer customer;
This means:
When you load an Order, the Customer is NOT loaded
When you access order.getCustomer(), JPA fetches it then
If you have 500 orders and access customer on each one → 500 queries
This was my code:
@GetMapping("/orders")
public ResponseEntity> getAllOrders() {
List orders = orderService.getAllOrders();
// This loop triggers the N+1 problem
List<OrderDTO> dtos = orders.stream()
.map(order -> new OrderDTO(
order.getId(),
order.getOrderNumber(),
order.getCustomer().getName() // Query executed here!
))
.collect(Collectors.toList());
return ResponseEntity.ok(dtos);
}
Every time the code accessed order.getCustomer().getName(), Hibernate fired a separate SQL query.
Result: 1 query to get orders + 500 queries to get customers = 501 total queries.
The Fix #1: Eager Loading (The Quick Fix)
The simplest solution: tell JPA to fetch the customer when loading the order.
@entity
public class Order {
@id
private Long id;
@ManyToOne(fetch = FetchType.EAGER) // Change to EAGER
private Customer customer;
}
Now Hibernate does:
SELECT order0_.id, order0_.customer_id, order0_.order_number, customer1_.id, customer1_.name, customer1_.email
FROM orders order0_
LEFT JOIN customers customer1_ ON order0_.customer_id = customer1_.id
Single query. 500 results.
Result:
Before: 501 queries, 2.8 seconds
After: 1 query, 120ms
That's 23x faster.
Why This Isn't Always The Answer
Problem: Eager loading loads customers even if you don't need them.
Example: If you have another endpoint that just needs order numbers:
@GetMapping("/orders/numbers")
public List getOrderNumbers() {
List orders = orderRepository.findAll(); // Loads 500 customers unnecessarily
return orders.stream()
.map(Order::getOrderNumber)
.collect(Collectors.toList());
}
Now you're loading data you don't use. Wastes memory and database resources.
Better approach: Use eager loading only where you need it.
The Fix #2: Fetch Join (The Proper Fix)
Instead of changing the entity, use JPQL fetch join in your query:
@Repository
public interface OrderRepository extends JpaRepository {
@Query("SELECT DISTINCT o FROM Order o " +
"LEFT JOIN FETCH o.customer c " +
"WHERE o.id IN :orderIds")
List<Order> findOrdersWithCustomers(@Param("orderIds") List<Long> orderIds);
// Or get all with customers
@Query("SELECT DISTINCT o FROM Order o " +
"LEFT JOIN FETCH o.customer c")
List<Order> findAllWithCustomers();
}
Now update your service:
@Service
public class OrderService {
@Autowired
private OrderRepository orderRepository;
public List<Order> getAllOrdersWithCustomers() {
return orderRepository.findAllWithCustomers(); // Uses fetch join
}
}
Hibernate generates:
SELECT DISTINCT order0_.id, order0_.customer_id, order0_.order_number,
customer1_.id, customer1_.name, customer1_.email
FROM orders order0_
LEFT JOIN customers customer1_ ON order0_.customer_id = customer1_.id
Single query. All data loaded.
Why this is better:
✅ Only loads customer when you need it
✅ Still uses one query (no N+1 problem)
✅ You control when eager loading happens
✅ Different queries can load different relationships
The Fix #3: Projection (The Advanced Fix)
Sometimes you don't need the full Order object. You just need specific fields:
public interface OrderDTO {
Long getId();
String getOrderNumber();
String getCustomerName(); // Comes from customer table
BigDecimal getTotalAmount();
}
@Repository
public interface OrderRepository extends JpaRepository {
@Query("SELECT new com.example.dto.OrderDTO(" +
"o.id, o.orderNumber, c.name, o.totalAmount) " +
"FROM Order o " +
"LEFT JOIN o.customer c")
List<OrderDTO> findAllOrderDTOs();
}
Hibernate generates:
SELECT order0_.id, order0_.order_number, customer1_.name, order0_.total_amount
FROM orders order0_
LEFT JOIN customers customer1_ ON order0_.customer_id = customer1_.id
Single query. Only the fields you need.
Why this is best for APIs:
✅ Single query (no N+1)
✅ Returns DTO directly (no mapping overhead)
✅ Database returns only needed columns
✅ Fastest option for REST responses
The Real-World Fix (What I Did)
In production, I did all three:
- Identified N+1 queries:
Enable query logging to spot N+1 problems
spring.jpa.properties.hibernate.generate_statistics=true
logging.level.org.hibernate.stat=debug
- Fixed the critical endpoints with fetch join:
@GetMapping("/api/v1/orders")
public ResponseEntity> getAllOrders(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
// Uses fetch join - single query with pagination
Page<OrderDTO> orders = orderRepository.findAllOrderDTOs(
PageRequest.of(page, size)
);
return ResponseEntity.ok(orders.getContent());
}
- Added Spring Data Specification for complex queries:
@Repository
public interface OrderRepository extends
JpaRepository,
JpaSpecificationExecutor {
// Specifications handle complex queries efficiently
}
@Service
public class OrderService {
public List searchOrders(OrderSearchCriteria criteria) {
return orderRepository.findAll((root, query, cb) -> {
Join customerJoin = root.join("customer", JoinType.LEFT);
// Complex query with joins - still single query
Predicate predicate = cb.and(
cb.like(customerJoin.get("name"), criteria.getCustomerName() + "%"),
cb.greaterThan(root.get("totalAmount"), criteria.getMinAmount())
);
return predicate;
});
}
}
The Results (Before & After)
Metric Before After Improvement
Queries per request 487 1 487x ↓
Response time 2.8s 120ms 23x ↓
Database load 95% CPU 15% CPU 80% ↓
Customer complaints 47 0 100% ↓
After the fix:
Orders endpoint latency: 120ms (was 2.8s)
Database CPU dropped from 95% to 15%
Concurrent users increased from 100 to 500 without slowdown
Zero customer complaints about slow orders
How To Prevent This In The Future
- Enable query counting in development:
spring.jpa.properties.hibernate.generate_statistics=true
logging.level.org.hibernate.stat=debug
- Monitor queries in your tests:
@test
@Transactional
public void testOrderFetch() {
// Hibernate counts queries during test
List orders = orderService.getAllOrders();
// If this shows > 1 query, N+1 problem detected
// The test fails before production sees it
}
- Use database profiling tools:
MySQL: Enable query logs and analyze slow queries
PostgreSQL: Enable auto_explain
Spring Boot Actuator: Monitor database metrics
- Code review checklist:
When you see this in a PR:
List orders = orderRepository.findAll();
orders.forEach(order -> {
String customerName = order.getCustomer().getName(); // RED FLAG
});
Ask: "Is there a way to fetch this with a single query?"
The Lesson
The N+1 query problem is invisible until it hits production.
It doesn't show up in:
❌ Unit tests (usually tiny datasets)
❌ Local development (cache hides the problem)
❌ Early production (low traffic)
It explodes when:
✅ Real data volume arrives
✅ Multiple concurrent users
✅ Database connection pool saturated
✅ API timeout starts happening
Prevention is easier than debugging:
Use fetch joins for relationships
Use projections for DTOs
Monitor queries in development
Test with realistic data volumes
Next Steps
If you're experiencing slow Spring Boot APIs:
Enable Hibernate query logging
Make a single API request
Count the SQL queries
If > 2-3 queries for simple endpoint → N+1 problem
Use fetch join or projection to fix
It usually takes 30 minutes to fix and saves your users hours of waiting.
That Tuesday at 2 AM was frustrating. But it taught me something valuable: always think about how many database queries your code executes.
Your users will thank you.
Questions? Comments? Drop them below! I read every single comment and reply within 24 hours.
Related Reading
Spring Boot with Hibernate: Why Your Queries Are Slow
JPA Fetch Strategies: Lazy vs Eager Loading Explained
How to Debug Spring Boot Database Performance
Next Article: "The Connection Pool Mistake That Cost Us $5,000 in RDS Bills"
Cross-posted from my blog: Spring Boot N+1 Query Problem
Follow for more production engineering insights on Java, Spring Boot, and distributed systems.