Common ORM Pitfalls and Production Best Practices
Common ORM Pitfalls and Production Best Practices
ORM frameworks like Hibernate are powerful but can quietly destroy performance if used carelessly. The N+1 query problem, missing indexes, and lazy loading surprises are the most frequent issues found in production Java applications. This post covers each pitfall and the fix.
The N+1 Query Problem
The N+1 problem happens when you fetch a list of entities and then access a lazy collection on each one. Hibernate fires one query for the parent list and then one additional query per entity for the collection. For 100 orders with 10 items each, that is 101 queries instead of 1 or 2.
// BAD: N+1 queries - one per order
List<Order> orders = repository.findAll();
for (Order order : orders) {
System.out.println(order.getItems().size()); // triggers a SELECT per order
}
Fix it with @EntityGraph or a JOIN FETCH in your JPQL query. This tells Hibernate to load the collection in the same query:
// GOOD: single query with JOIN FETCH
@Query("SELECT o FROM Order o JOIN FETCH o.items")
List<Order> findAllWithItems();
// OR using EntityGraph
@EntityGraph(attributePaths = {"items"})
List<Order> findAll();
Lazy vs Eager Loading
Hibernate defaults to FetchType.LAZY for collections, which is correct. Do not change it to EAGER globally or on the owning entity - this causes Hibernate to load every collection every time you fetch the parent, even when you do not need the data.
// BAD: eager loading pulls in everything
@OneToMany(fetch = FetchType.EAGER)
private List<Item> items;
// GOOD: lazy loading with explicit fetch when needed
@OneToMany(fetch = FetchType.LAZY)
private List<Item> items;
Lazy loading can cause a LazyInitializationException if you access the collection outside the persistence context. Use @Transactional on the service method or fetch eagerly in the specific query that needs the data.
Missing Indexes
Hibernate does not create indexes for foreign key columns or fields used in WHERE clauses. Add them manually in your migration scripts. Without indexes, queries that filter by user_id or category perform full table scans on large tables:
-- Always index foreign keys and commonly filtered columns
CREATE INDEX idx_products_category ON products(category);
CREATE INDEX idx_orders_user_id ON orders(user_id);
CREATE INDEX idx_orders_created_at ON orders(created_at);
Mass Updates and Deletes
Using findAll() and looping to update each entity one by one is slow because Hibernate generates one UPDATE per entity. For bulk operations, use a bulk update query:
// BAD: N UPDATE statements
List<Product> products = repository.findByCategory("old");
for (Product p : products) {
p.setCategory("legacy");
}
// GOOD: single UPDATE statement
@Modifying
@Query("UPDATE Product p SET p.category = 'legacy' WHERE p.category = 'old'")
int markLegacyProducts();
Bulk updates bypass the first-level cache, so call entityManager.clear() afterward if you continue using entities from the same session.
Key Points
- The N+1 query problem is the most common ORM performance killer - fix it with JOIN FETCH or @EntityGraph.
- Keep collections LAZY by default and fetch eagerly only in the specific query that needs them.
- Add database indexes for foreign keys and columns used in WHERE clauses.
- Use bulk UPDATE/DELETE queries instead of loading entities and modifying them in a loop.
- Call
entityManager.clear()after bulk operations to keep the first-level cache consistent. - Monitor generated SQL with
spring.jpa.show-sql=trueduring development to catch issues early.