Hibernate does a lot for you — and that is exactly why it easily hides problems until your application starts slowing down or corrupting data. This article is a collection of the pitfalls you will run into most often.
equals and hashCode on an entity
When an entity is put into a HashSet or HashMap, Java uses equals and hashCode. If you do not override them explicitly, the implementation from Object is used — based on the object reference. That is almost always wrong.
The first instinct is to generate them from id. But there is a trap: until the entity is saved, id is null. Two new objects with id == null will turn out to be "equal" — or both will land in a set with the wrong hash, and after a flush the hash changes and you can no longer find them.
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
// Bad: equals/hashCode by id — id == null for new objects
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Product p)) return false;
return id != null && id.equals(p.id);
}
@Override
public int hashCode() {
return getClass().hashCode(); // constant — safe, but slow on large collections
}
}
The version above is "null-safe": equals returns false if one of the ids is null, and hashCode is constant (the hash does not change when the object is saved). This is a workable minimum.
The better option is a business key: a field that is unique and stable even before saving (a SKU, an email, a UUID generated in code rather than by the database).
@Entity
public class Product {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(unique = true, nullable = false)
private String sku; // business key
@Override
public boolean equals(Object o) {
if (this == o) return true;
if (!(o instanceof Product p)) return false;
return sku != null && sku.equals(p.sku);
}
@Override
public int hashCode() {
return Objects.hashCode(sku);
}
}
A short formula: the hash must not change when the object moves from transient to managed — which means you cannot compute it from id if the database assigns the id.
Open Session in View
Open Session in View (OSIV) is a pattern where the Hibernate session stays open for the entire duration of the HTTP request, including rendering the response. In Spring Boot it is enabled by default (spring.jpa.open-in-view=true).
At first glance it looks convenient: lazy collections load right in the template, no LazyInitializationException. In practice there are two hidden harms:
- A database connection is held for the whole request, including slow template rendering. Under load the connection pool runs out quickly.
- It masks N+1: queries fire off to the database from the presentation layer, where nobody expects or controls them.
# application.yml — disable OSIV
spring:
jpa:
open-in-view: false
Once disabled, LazyInitializationException becomes explicit — right where data was not loaded inside a transaction. That is a good thing: the problem is visible rather than hidden. The solution is to load the data you need in the service layer (via JOIN FETCH or EntityGraph) and return a DTO, not an entity.
Returning an entity from a controller instead of a DTO
An entity is not a DTO. If you return an @Entity directly from a controller to Jackson, several unpleasant things happen:
- Leaking the internal structure: the client sees all fields, including technical and private ones.
- Recursion during serialization: bidirectional relationships (
@OneToMany+@ManyToOne) lead to an infinite loop — you need@JsonIgnoreor@JsonManagedReference, which clutters the domain code. - Unexpected loading: Jackson will try to walk all fields of the entity, including lazy collections — if the session is still open (OSIV), Hibernate will run extra queries.
// Bad: returning the entity straight from the controller
@GetMapping("/products/{id}")
public Product getProduct(@PathVariable Long id) {
return productRepository.findById(id).orElseThrow();
}
// Good: convert to a DTO in the service layer
@GetMapping("/products/{id}")
public ProductDto getProduct(@PathVariable Long id) {
return productService.getById(id); // maps to a DTO inside
}
A dedicated DTO for each response is not bureaucracy — it is the boundary between the internal model and the public contract.
CascadeType.ALL and orphanRemoval — a dangerous combination
CascadeType.ALL propagates all operations (PERSIST, MERGE, REMOVE, REFRESH, DETACH) from the parent to child entities. In most cases this is excessive.
The combination CascadeType.ALL + orphanRemoval = true is especially dangerous: if you remove a child object from the parent's collection, Hibernate deletes it from the database. This is non-obvious and easy to do by accident.
@Entity
public class Order {
@OneToMany(mappedBy = "order",
cascade = CascadeType.ALL, // includes REMOVE
orphanRemoval = true) // deletes the row from items when removed from the collection
private List<OrderItem> items = new ArrayList<>();
}
// In code:
order.getItems().clear(); // <-- this will delete ALL rows in order_item for this order!
Recommendation: use only the cascade types you actually need. Usually CascadeType.PERSIST and CascadeType.MERGE are enough. Add REMOVE only where child objects make no sense without the parent (for example, receipt line items).
// Explicit and safe
@OneToMany(mappedBy = "order", cascade = {CascadeType.PERSIST, CascadeType.MERGE})
private List<OrderItem> items = new ArrayList<>();
Bulk operations done one object at a time
The standard approach through a JPA repository looks like this: load the entities, change them in a loop, save. With thousands of records this turns into thousands of separate UPDATEs:
// Bad: N UPDATE queries to the database
List<Product> products = productRepository.findAll();
for (Product p : products) {
p.setPrice(p.getPrice().multiply(BigDecimal.valueOf(1.1)));
productRepository.save(p); // flush on each iteration
}
Instead, use a bulk query through JPQL or native SQL:
// Good: a single UPDATE
@Modifying
@Query("UPDATE Product p SET p.price = p.price * 1.1 WHERE p.category = :category")
int increasePricesByCategory(@Param("category") String category);
An important nuance: after a @Modifying query the first-level cache (persistence context) does not know about the changes — it is stale. You need to either add @Modifying(clearAutomatically = true) or call entityManager.clear() manually before the next read.
@Modifying(clearAutomatically = true, flushAutomatically = true)
@Query("UPDATE Product p SET p.price = p.price * 1.1 WHERE p.category = :category")
int increasePricesByCategory(@Param("category") String category);
For more on the persistence context and caching, see the article Persistence Context.
merge vs save: what happens
In Spring Data JPA the save() method does one of two things depending on the state of the object:
- If
id == null(or the object is new according toPersistable) — it callsentityManager.persist(). - If
idis set — it callsentityManager.merge().
merge behaves in a non-obvious way: it does not update the object you passed in; it returns a new managed instance from the persistence context. The original object stays detached.
Product detached = new Product();
detached.setId(42L);
detached.setName("New name");
Product managed = productRepository.save(detached);
// detached is still detached, its changes are not tracked!
// managed is the managed copy, and that is what you should keep working with
managed.setPrice(BigDecimal.valueOf(999)); // this will hit the database on flush
detached.setPrice(BigDecimal.valueOf(0)); // this is saved NOWHERE
A short formula: after save(), work with the returned object, not the one you passed in.
If you need to update specific fields of an entity, it is better to load it from the database and change it within a transaction — then merge is not needed at all.
In short
equals/hashCodebyidare dangerous: before saving,id == null. Use a constanthashCodeor a business key.- OSIV holds a connection for the whole request and hides N+1 — disable it and load data explicitly inside a transaction.
- Do not return an
@Entityfrom a controller — use a DTO so you do not leak the internal model or get unexpected queries during serialization. CascadeType.ALL + orphanRemovaldelete rows onclear()of the collection — use them only deliberately, and prefer an explicit set of types.- Do bulk changes with bulk queries (
@Modifying + @Query), not in a loop; afterwards clear the first-level cache. save()with a non-nullidcallsmerge— the returned object ismanaged, the original staysdetached.
What to read next
- Persistence Context — how Hibernate tracks changes and when they are flushed to the database.
- Lazy vs Eager loading — why
LazyInitializationExceptionhappens and how to avoid it properly. - The N+1 problem — diagnosing and solving the most common cause of slow queries.
- Spring Data JPA — repositories on top of Hibernate: methods, derived queries,
@Query.