<?xml version="1.0" encoding="utf-8" standalone="yes"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Performance on Vitalii Sukach</title><link>https://sukachvitally.github.io/tags/performance/</link><description>Recent content in Performance on Vitalii Sukach</description><generator>Hugo</generator><language>en-us</language><lastBuildDate>Sat, 07 Nov 2026 10:00:00 +0000</lastBuildDate><atom:link href="https://sukachvitally.github.io/tags/performance/index.xml" rel="self" type="application/rss+xml"/><item><title>5 JPA/Hibernate Mistakes That Quietly Kill Your Spring Boot Performance</title><link>https://sukachvitally.github.io/posts/5-jpa-hibernate-mistakes-that-quietly-kill-your-spring-boot-performance/</link><pubDate>Sat, 07 Nov 2026 10:00:00 +0000</pubDate><guid>https://sukachvitally.github.io/posts/5-jpa-hibernate-mistakes-that-quietly-kill-your-spring-boot-performance/</guid><description>Open Session in View, EAGER to-ones, in-memory pagination, deleteBy and read-only entities: 5 JPA defaults that only hurt with real data.</description><content:encoded><![CDATA[<p><em>Everything works in dev. Then production gets real data.</em></p>
<p><img alt="A cartoon bear waiter rushes tiny plates tagged &ldquo;3 ms&rdquo; out of a kitchen where a blue elephant relaxes, while a long line of customers waits at the door marked API" loading="lazy" src="https://sukachvitally.github.io/posts/5-jpa-hibernate-mistakes-that-quietly-kill-your-spring-boot-performance/image-01.jpg"></p>
<p>Here&rsquo;s how it usually goes. Spring Boot app, Spring Data JPA, Postgres behind it. Every query in the log takes 3 ms. The database is bored. And yet the API is slow, the connection pool runs dry at lunchtime, and the nightly export job is still running at breakfast.</p>
<p>Nobody wrote a slow query. Hibernate just did a lot of things nobody asked it to do, very quickly, many times.</p>
<p>In <a href="/posts/5-postgresql-mistakes-i-see-in-every-spring-boot-app/">5 PostgreSQL Mistakes I See in Every Spring Boot App</a> I covered the database side, including the cartesian product you get from <code>JOIN FETCH</code> on several collections and <code>IDENTITY</code> quietly turning off batch inserts. This one is about the JPA side: five defaults and habits I find in almost every Spring Boot codebase I review, and what each of them actually makes Hibernate do.</p>
<p>The examples use Spring Boot 4, Hibernate ORM 7 and Java 21, with SQL logging on:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-properties" data-lang="properties"><span class="line"><span class="cl"><span class="na">logging.level.org.hibernate.SQL</span><span class="o">=</span><span class="s">DEBUG</span>
</span></span><span class="line"><span class="cl"><span class="na">logging.level.org.hibernate.orm.jdbc.bind</span><span class="o">=</span><span class="s">TRACE</span>
</span></span></code></pre></div><h3 id="mistake-1-leaving-open-session-in-view-on">Mistake 1: Leaving Open Session in View on</h3>
<p><img alt="Ten bears hold all ten DB connection keys while waiting on hold for the pricing service, as a bored elephant sits at reception and a queue of people waits" loading="lazy" src="https://sukachvitally.github.io/posts/5-jpa-hibernate-mistakes-that-quietly-kill-your-spring-boot-performance/image-02.jpg"></p>
<p>Here&rsquo;s an endpoint that looks completely harmless:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="nd">@RestController</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nd">@RequestMapping</span><span class="p">(</span><span class="s">&#34;/orders&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kd">class</span> <span class="nc">OrderController</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kd">private</span><span class="w"> </span><span class="kd">final</span><span class="w"> </span><span class="n">OrderService</span><span class="w"> </span><span class="n">orderService</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kd">private</span><span class="w"> </span><span class="kd">final</span><span class="w"> </span><span class="n">PricingClient</span><span class="w"> </span><span class="n">pricingClient</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">OrderController</span><span class="p">(</span><span class="n">OrderService</span><span class="w"> </span><span class="n">orderService</span><span class="p">,</span><span class="w"> </span><span class="n">PricingClient</span><span class="w"> </span><span class="n">pricingClient</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">this</span><span class="p">.</span><span class="na">orderService</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">orderService</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">this</span><span class="p">.</span><span class="na">pricingClient</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">pricingClient</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nd">@GetMapping</span><span class="p">(</span><span class="s">&#34;/{id}&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">OrderView</span><span class="w"> </span><span class="nf">get</span><span class="p">(</span><span class="nd">@PathVariable</span><span class="w"> </span><span class="n">Long</span><span class="w"> </span><span class="n">id</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">Order</span><span class="w"> </span><span class="n">order</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">orderService</span><span class="p">.</span><span class="na">findOrder</span><span class="p">(</span><span class="n">id</span><span class="p">);</span><span class="w">                                   </span><span class="c1">// @Transactional, ~5 ms</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">BigDecimal</span><span class="w"> </span><span class="n">delivery</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">pricingClient</span><span class="p">.</span><span class="na">estimateDelivery</span><span class="p">(</span><span class="n">order</span><span class="p">.</span><span class="na">getShippingCountry</span><span class="p">());</span><span class="w"> </span><span class="c1">// HTTP call, ~300 ms</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">return</span><span class="w"> </span><span class="n">OrderView</span><span class="p">.</span><span class="na">from</span><span class="p">(</span><span class="n">order</span><span class="p">,</span><span class="w"> </span><span class="n">delivery</span><span class="p">);</span><span class="w">                                     </span><span class="c1">// walks order.getItems()</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>The service method is <code>@Transactional(readOnly = true)</code> and does one <code>findById</code>. The transaction is over the moment it returns. So the connection goes back to the pool, right?</p>
<p>No. <code>spring.jpa.open-in-view</code> is <code>true</code> by default, so Spring registers an <code>OpenEntityManagerInViewInterceptor</code> for every web request. It opens the <code>EntityManager</code> when the request starts and closes it after the response is written. Spring also configures Hibernate to hold on to the JDBC connection once it has one, until the session closes. Here&rsquo;s the log:</p>
<pre tabindex="0"><code>select o1_0.id,o1_0.created_at,c1_0.id,c1_0.email,c1_0.name,o1_0.shipping_country,o1_0.status from orders o1_0 left join customers c1_0 on c1_0.id=o1_0.customer_id where o1_0.id=?
                                  -- transaction committed, connection still held
                                  -- ~300 ms waiting for the pricing service
select i1_0.order_id,i1_0.id,i1_0.price,i1_0.product_id,i1_0.quantity from order_items i1_0 where i1_0.order_id=?
</code></pre><p>The second query runs from <code>OrderView.from()</code> in the controller, outside any transaction, because the session is still open and the lazy <code>items</code> collection can still load. If you return the entity directly, Jackson does the same thing during serialization.</p>
<p>Now the arithmetic. The request does about 15 ms of SQL but holds a connection for about 320 ms. HikariCP&rsquo;s default pool has 10 connections, so the whole service tops out at 10 / 0.32 s ≈ <strong>31 requests per second</strong>. With the connection held only for the SQL it would be around 660. At 50 requests per second, threads start queueing for a connection, and 30 seconds later:</p>
<pre tabindex="0"><code>HikariPool-1 - Connection is not available, request timed out after 30000ms (total=10, active=10, idle=0, waiting=42)
</code></pre><p>Meanwhile, <code>pg_stat_activity</code> shows your ten connections as <code>idle</code>. The database isn&rsquo;t slow. It&rsquo;s waiting for you.</p>
<p><img alt="Request timeline: with Open Session in View the connection is held from the first query until the response is written, without it only during the service call" loading="lazy" src="https://sukachvitally.github.io/posts/5-jpa-hibernate-mistakes-that-quietly-kill-your-spring-boot-performance/diagram-osiv.jpg"></p>
<p><strong>The fix:</strong> turn it off, and load everything the response needs inside the service:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-properties" data-lang="properties"><span class="line"><span class="cl"><span class="na">spring.jpa.open-in-view</span><span class="o">=</span><span class="s">false</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">public</span><span class="w"> </span><span class="kd">interface</span> <span class="nc">OrderRepository</span><span class="w"> </span><span class="kd">extends</span><span class="w"> </span><span class="n">JpaRepository</span><span class="o">&lt;</span><span class="n">Order</span><span class="p">,</span><span class="w"> </span><span class="n">Long</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nd">@EntityGraph</span><span class="p">(</span><span class="n">attributePaths</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="p">{</span><span class="s">&#34;customer&#34;</span><span class="p">,</span><span class="w"> </span><span class="s">&#34;items&#34;</span><span class="p">})</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">Optional</span><span class="o">&lt;</span><span class="n">Order</span><span class="o">&gt;</span><span class="w"> </span><span class="nf">findWithCustomerAndItemsById</span><span class="p">(</span><span class="n">Long</span><span class="w"> </span><span class="n">id</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="nd">@Transactional</span><span class="p">(</span><span class="n">readOnly</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">true</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kd">public</span><span class="w"> </span><span class="n">OrderDetails</span><span class="w"> </span><span class="nf">findOrder</span><span class="p">(</span><span class="n">Long</span><span class="w"> </span><span class="n">id</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">Order</span><span class="w"> </span><span class="n">order</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">orderRepository</span><span class="p">.</span><span class="na">findWithCustomerAndItemsById</span><span class="p">(</span><span class="n">id</span><span class="p">).</span><span class="na">orElseThrow</span><span class="p">();</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="n">OrderDetails</span><span class="p">.</span><span class="na">from</span><span class="p">(</span><span class="n">order</span><span class="p">);</span><span class="w">   </span><span class="c1">// map to a DTO while the session is still open</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>The first thing you&rsquo;ll see after switching it off is <code>LazyInitializationException</code> in places you didn&rsquo;t know were lazy-loading. That&rsquo;s the point. Each one is a hidden query that used to run from a controller or a JSON serializer. Fetch it explicitly in the service, and it stops being hidden.</p>
<p><strong>How to check your app:</strong></p>
<ul>
<li>If you never set the property, Spring Boot tells you at startup: <code>spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering.</code></li>
<li>Compare HikariCP&rsquo;s <code>hikaricp.connections.usage</code> metric (how long a connection is held) with your SQL time. If usage is 10× the query time, something is holding connections.</li>
</ul>
<p><strong>Read more:</strong> <a href="https://docs.spring.io/spring-boot/reference/data/sql.html#data.sql.jpa-and-spring-data.open-entity-manager-in-view">Spring Boot reference: Open EntityManager in View</a></p>
<h3 id="mistake-2-trusting-the-default-fetch-type-on-manytoone">Mistake 2: Trusting the default fetch type on @ManyToOne</h3>
<p><img alt="A bear waits for a single envelope while a blue elephant delivers a chain of boxes: Payment, Order, Customer, Country" loading="lazy" src="https://sukachvitally.github.io/posts/5-jpa-hibernate-mistakes-that-quietly-kill-your-spring-boot-performance/image-03.jpg"></p>
<p>Collections are lazy by default. To-one associations aren&rsquo;t. The JPA spec says <code>@ManyToOne</code> and <code>@OneToOne</code> are <code>EAGER</code> unless you say otherwise, and most entities I see never say otherwise:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="c1">// Payment.java</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nd">@Entity</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nd">@Table</span><span class="p">(</span><span class="n">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&#34;payments&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kd">public</span><span class="w"> </span><span class="kd">class</span> <span class="nc">Payment</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nd">@Id</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nd">@GeneratedValue</span><span class="p">(</span><span class="n">strategy</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">GenerationType</span><span class="p">.</span><span class="na">SEQUENCE</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kd">private</span><span class="w"> </span><span class="n">Long</span><span class="w"> </span><span class="n">id</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nd">@ManyToOne</span><span class="w">                          </span><span class="c1">// EAGER by default</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nd">@JoinColumn</span><span class="p">(</span><span class="n">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&#34;order_id&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kd">private</span><span class="w"> </span><span class="n">Order</span><span class="w"> </span><span class="n">order</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nd">@Enumerated</span><span class="p">(</span><span class="n">EnumType</span><span class="p">.</span><span class="na">STRING</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kd">private</span><span class="w"> </span><span class="n">PaymentStatus</span><span class="w"> </span><span class="n">status</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kd">private</span><span class="w"> </span><span class="n">BigDecimal</span><span class="w"> </span><span class="n">amount</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="c1">// Order.java</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nd">@Entity</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nd">@Table</span><span class="p">(</span><span class="n">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&#34;orders&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kd">public</span><span class="w"> </span><span class="kd">class</span> <span class="nc">Order</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nd">@Id</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nd">@GeneratedValue</span><span class="p">(</span><span class="n">strategy</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">GenerationType</span><span class="p">.</span><span class="na">SEQUENCE</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kd">private</span><span class="w"> </span><span class="n">Long</span><span class="w"> </span><span class="n">id</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nd">@ManyToOne</span><span class="w">                          </span><span class="c1">// EAGER by default</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nd">@JoinColumn</span><span class="p">(</span><span class="n">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&#34;customer_id&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kd">private</span><span class="w"> </span><span class="n">Customer</span><span class="w"> </span><span class="n">customer</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nd">@OneToMany</span><span class="p">(</span><span class="n">mappedBy</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&#34;order&#34;</span><span class="p">)</span><span class="w">      </span><span class="c1">// LAZY by default</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kd">private</span><span class="w"> </span><span class="n">List</span><span class="o">&lt;</span><span class="n">OrderItem</span><span class="o">&gt;</span><span class="w"> </span><span class="n">items</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">new</span><span class="w"> </span><span class="n">ArrayList</span><span class="o">&lt;&gt;</span><span class="p">();</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nd">@Enumerated</span><span class="p">(</span><span class="n">EnumType</span><span class="p">.</span><span class="na">STRING</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kd">private</span><span class="w"> </span><span class="n">OrderStatus</span><span class="w"> </span><span class="n">status</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kd">private</span><span class="w"> </span><span class="n">String</span><span class="w"> </span><span class="n">shippingCountry</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kd">private</span><span class="w"> </span><span class="n">Instant</span><span class="w"> </span><span class="n">createdAt</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p><code>paymentRepository.findById(42L)</code> gives you a chain of joins:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">select</span><span class="w"> </span><span class="n">p1_0</span><span class="p">.</span><span class="n">id</span><span class="p">,</span><span class="n">p1_0</span><span class="p">.</span><span class="n">amount</span><span class="p">,</span><span class="n">o1_0</span><span class="p">.</span><span class="n">id</span><span class="p">,</span><span class="n">o1_0</span><span class="p">.</span><span class="n">created_at</span><span class="p">,</span><span class="n">c1_0</span><span class="p">.</span><span class="n">id</span><span class="p">,</span><span class="n">c1_0</span><span class="p">.</span><span class="n">email</span><span class="p">,</span><span class="n">c1_0</span><span class="p">.</span><span class="n">name</span><span class="p">,</span><span class="n">o1_0</span><span class="p">.</span><span class="n">shipping_country</span><span class="p">,</span><span class="n">o1_0</span><span class="p">.</span><span class="n">status</span><span class="p">,</span><span class="n">p1_0</span><span class="p">.</span><span class="n">status</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="k">from</span><span class="w"> </span><span class="n">payments</span><span class="w"> </span><span class="n">p1_0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="k">left</span><span class="w"> </span><span class="k">join</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="n">o1_0</span><span class="w"> </span><span class="k">on</span><span class="w"> </span><span class="n">o1_0</span><span class="p">.</span><span class="n">id</span><span class="o">=</span><span class="n">p1_0</span><span class="p">.</span><span class="n">order_id</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="k">left</span><span class="w"> </span><span class="k">join</span><span class="w"> </span><span class="n">customers</span><span class="w"> </span><span class="n">c1_0</span><span class="w"> </span><span class="k">on</span><span class="w"> </span><span class="n">c1_0</span><span class="p">.</span><span class="n">id</span><span class="o">=</span><span class="n">o1_0</span><span class="p">.</span><span class="n">customer_id</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="k">where</span><span class="w"> </span><span class="n">p1_0</span><span class="p">.</span><span class="n">id</span><span class="o">=?</span><span class="w">
</span></span></span></code></pre></div><p>For one row that&rsquo;s tolerable, and that&rsquo;s why nobody notices. But the chain grows every time someone adds a to-one anywhere above it. Add <code>Customer.country</code> and every <code>findById</code> on <code>Payment</code> now joins four tables.</p>
<p>The real damage shows up with a query. Here&rsquo;s a retry job that needs pending payments, just their IDs and amounts:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="n">List</span><span class="o">&lt;</span><span class="n">Payment</span><span class="o">&gt;</span><span class="w"> </span><span class="nf">findAllByStatus</span><span class="p">(</span><span class="n">PaymentStatus</span><span class="w"> </span><span class="n">status</span><span class="p">);</span><span class="w">
</span></span></span></code></pre></div><p>JPQL doesn&rsquo;t turn <code>EAGER</code> mappings into joins. Hibernate runs the query you wrote, and then, because it promised to load <code>order</code> eagerly, it loads every order with a separate select:</p>
<pre tabindex="0"><code>select p1_0.id,p1_0.amount,p1_0.order_id,p1_0.status from payments p1_0 where p1_0.status=?
select o1_0.id,o1_0.created_at,c1_0.id,c1_0.email,c1_0.name,o1_0.shipping_country,o1_0.status from orders o1_0 left join customers c1_0 on c1_0.id=o1_0.customer_id where o1_0.id=?
select o1_0.id,o1_0.created_at,c1_0.id,c1_0.email,c1_0.name,o1_0.shipping_country,o1_0.status from orders o1_0 left join customers c1_0 on c1_0.id=o1_0.customer_id where o1_0.id=?
... 498 more
</code></pre><p>500 pending payments, <strong>501 queries</strong>, and the code never calls <code>getOrder()</code> once. It&rsquo;s N+1 without a single lazy load, which is why people don&rsquo;t recognize it.</p>
<p><strong>The fix:</strong> <code>LAZY</code> on every to-one, no exceptions, then fetch explicitly where you actually need the parent:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="nd">@ManyToOne</span><span class="p">(</span><span class="n">fetch</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">FetchType</span><span class="p">.</span><span class="na">LAZY</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nd">@JoinColumn</span><span class="p">(</span><span class="n">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&#34;order_id&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kd">private</span><span class="w"> </span><span class="n">Order</span><span class="w"> </span><span class="n">order</span><span class="p">;</span><span class="w">
</span></span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="nd">@Query</span><span class="p">(</span><span class="s">&#34;&#34;&#34;
</span></span></span><span class="line"><span class="cl"><span class="s">    select p from Payment p
</span></span></span><span class="line"><span class="cl"><span class="s">    join fetch p.order o
</span></span></span><span class="line"><span class="cl"><span class="s">    join fetch o.customer
</span></span></span><span class="line"><span class="cl"><span class="s">    where p.status = :status
</span></span></span><span class="line"><span class="cl"><span class="s">    &#34;&#34;&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="n">List</span><span class="o">&lt;</span><span class="n">Payment</span><span class="o">&gt;</span><span class="w"> </span><span class="nf">findAllWithOrderAndCustomerByStatus</span><span class="p">(</span><span class="n">PaymentStatus</span><span class="w"> </span><span class="n">status</span><span class="p">);</span><span class="w">
</span></span></span></code></pre></div><p>Fetch-joining to-ones is cheap: still one row per payment, no multiplication. (Collections are a different story. That&rsquo;s the cartesian product from the PostgreSQL article.) And if hydration cost is new to you, <a href="/posts/when-one-fast-query-becomes-a-production-nightmare-the-dehydration-problem-in-hibernate/">The Dehydration Problem in Hibernate</a> explains why loading entities you don&rsquo;t use is expensive even when the SQL is fast.</p>
<p>One trap: on the inverse side of a <code>@OneToOne</code> (the one with <code>mappedBy</code>), Hibernate can&rsquo;t know whether the related row exists without querying, so it may load it eagerly even with <code>LAZY</code>. Map the association from the owning side only, or share the primary key with <code>@MapsId</code>.</p>
<p><strong>How to check your app:</strong></p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-bash" data-lang="bash"><span class="line"><span class="cl">grep -rn -E <span class="s2">&#34;@(ManyToOne|OneToOne)&#34;</span> src/main/java <span class="p">|</span> grep -v <span class="s2">&#34;LAZY&#34;</span>
</span></span></code></pre></div><p>Then make the query count part of a test. Turn on statistics in the test profile:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-properties" data-lang="properties"><span class="line"><span class="cl"><span class="na">spring.jpa.properties.hibernate.generate_statistics</span><span class="o">=</span><span class="s">true</span>
</span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kn">import static</span><span class="w"> </span><span class="nn">org.assertj.core.api.Assertions.assertThat</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kn">import</span><span class="w"> </span><span class="nn">jakarta.persistence.EntityManagerFactory</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kn">import</span><span class="w"> </span><span class="nn">org.hibernate.SessionFactory</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kn">import</span><span class="w"> </span><span class="nn">org.hibernate.stat.Statistics</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kn">import</span><span class="w"> </span><span class="nn">org.junit.jupiter.api.Test</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kn">import</span><span class="w"> </span><span class="nn">org.springframework.beans.factory.annotation.Autowired</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kn">import</span><span class="w"> </span><span class="nn">org.springframework.boot.test.context.SpringBootTest</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nd">@SpringBootTest</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kd">class</span> <span class="nc">PaymentQueryCountTest</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nd">@Autowired</span><span class="w"> </span><span class="n">PaymentRepository</span><span class="w"> </span><span class="n">paymentRepository</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nd">@Autowired</span><span class="w"> </span><span class="n">EntityManagerFactory</span><span class="w"> </span><span class="n">entityManagerFactory</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nd">@Test</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kt">void</span><span class="w"> </span><span class="nf">loadingPendingPaymentsIsOneQuery</span><span class="p">()</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">Statistics</span><span class="w"> </span><span class="n">stats</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">entityManagerFactory</span><span class="p">.</span><span class="na">unwrap</span><span class="p">(</span><span class="n">SessionFactory</span><span class="p">.</span><span class="na">class</span><span class="p">).</span><span class="na">getStatistics</span><span class="p">();</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">stats</span><span class="p">.</span><span class="na">clear</span><span class="p">();</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">paymentRepository</span><span class="p">.</span><span class="na">findAllByStatus</span><span class="p">(</span><span class="n">PaymentStatus</span><span class="p">.</span><span class="na">PENDING</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">assertThat</span><span class="p">(</span><span class="n">stats</span><span class="p">.</span><span class="na">getPrepareStatementCount</span><span class="p">()).</span><span class="na">isEqualTo</span><span class="p">(</span><span class="n">1</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>I&rsquo;ll reuse this test for the next mistakes. It&rsquo;s the cheapest performance test you&rsquo;ll ever write.</p>
<p><strong>Read more:</strong> <a href="https://docs.hibernate.org/orm/7.0/userguide/html_single/Hibernate_User_Guide.html#fetching">Hibernate ORM 7 User Guide: Fetching</a></p>
<h3 id="mistake-3-paginating-a-join-fetch-of-a-collection">Mistake 3: Paginating a JOIN FETCH of a collection</h3>
<p><img alt="A truck dumps a mountain of 500,000 boxes, and a bear picks 20 of them into a basket labeled Page 1 while the rest go into a dumpster" loading="lazy" src="https://sukachvitally.github.io/posts/5-jpa-hibernate-mistakes-that-quietly-kill-your-spring-boot-performance/image-04.jpg"></p>
<p>An order list screen, 20 per page, with items, because the screen shows them:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="nd">@Query</span><span class="p">(</span><span class="n">value</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&#34;&#34;&#34;
</span></span></span><span class="line"><span class="cl"><span class="s">        select o from Order o
</span></span></span><span class="line"><span class="cl"><span class="s">        join fetch o.items
</span></span></span><span class="line"><span class="cl"><span class="s">        where o.status = :status
</span></span></span><span class="line"><span class="cl"><span class="s">        &#34;&#34;&#34;</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">       </span><span class="n">countQuery</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&#34;select count(o) from Order o where o.status = :status&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="n">Page</span><span class="o">&lt;</span><span class="n">Order</span><span class="o">&gt;</span><span class="w"> </span><span class="nf">findWithItemsByStatus</span><span class="p">(</span><span class="n">OrderStatus</span><span class="w"> </span><span class="n">status</span><span class="p">,</span><span class="w"> </span><span class="n">Pageable</span><span class="w"> </span><span class="n">pageable</span><span class="p">);</span><span class="w">
</span></span></span></code></pre></div><p>Called with <code>PageRequest.of(0, 20, Sort.by(&quot;createdAt&quot;).descending())</code>. Here&rsquo;s the SQL:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-sql" data-lang="sql"><span class="line"><span class="cl"><span class="k">select</span><span class="w"> </span><span class="n">o1_0</span><span class="p">.</span><span class="n">id</span><span class="p">,</span><span class="n">o1_0</span><span class="p">.</span><span class="n">created_at</span><span class="p">,</span><span class="n">o1_0</span><span class="p">.</span><span class="n">customer_id</span><span class="p">,</span><span class="n">o1_0</span><span class="p">.</span><span class="n">shipping_country</span><span class="p">,</span><span class="n">o1_0</span><span class="p">.</span><span class="n">status</span><span class="p">,</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">       </span><span class="n">i1_0</span><span class="p">.</span><span class="n">order_id</span><span class="p">,</span><span class="n">i1_0</span><span class="p">.</span><span class="n">id</span><span class="p">,</span><span class="n">i1_0</span><span class="p">.</span><span class="n">price</span><span class="p">,</span><span class="n">i1_0</span><span class="p">.</span><span class="n">product_id</span><span class="p">,</span><span class="n">i1_0</span><span class="p">.</span><span class="n">quantity</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="k">from</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="n">o1_0</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="k">join</span><span class="w"> </span><span class="n">order_items</span><span class="w"> </span><span class="n">i1_0</span><span class="w"> </span><span class="k">on</span><span class="w"> </span><span class="n">o1_0</span><span class="p">.</span><span class="n">id</span><span class="o">=</span><span class="n">i1_0</span><span class="p">.</span><span class="n">order_id</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="k">where</span><span class="w"> </span><span class="n">o1_0</span><span class="p">.</span><span class="n">status</span><span class="o">=?</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="k">order</span><span class="w"> </span><span class="k">by</span><span class="w"> </span><span class="n">o1_0</span><span class="p">.</span><span class="n">created_at</span><span class="w"> </span><span class="k">desc</span><span class="w">
</span></span></span></code></pre></div><p>No <code>LIMIT</code>. No <code>OFFSET</code>. And that&rsquo;s deliberate: a <code>LIMIT 20</code> in SQL would cut rows, not orders. Twenty rows might be two orders with ten items each, or one order cut in half. So Hibernate fetches everything, builds every entity, and does the pagination in memory. The only trace is a warning:</p>
<pre tabindex="0"><code>WARN  HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory
</code></pre><p>This is the behavior up to Hibernate 7.3. Starting with 7.4, the docs say the limit is applied in memory only when the database doesn&rsquo;t support <code>LIMIT</code> inside a subquery, so on newer versions check your SQL log before assuming you&rsquo;re affected.</p>
<p>With 50,000 open orders and 10 items each, page 1 of 20 orders loads <strong>500,000 rows</strong>. They go over the network, Hibernate hydrates 550,000 entities into the persistence context, keeps 20 orders, and throws the rest away. Page 2 costs exactly the same. In dev, with 30 orders, it&rsquo;s instant.</p>
<p><img alt="The database returns 500,000 rows, Hibernate builds every entity and keeps 20 for the page" loading="lazy" src="https://sukachvitally.github.io/posts/5-jpa-hibernate-mistakes-that-quietly-kill-your-spring-boot-performance/diagram-pagination.jpg"></p>
<p><strong>The fix:</strong> page the IDs, then fetch the entities by ID.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="nd">@Query</span><span class="p">(</span><span class="s">&#34;select o.id from Order o where o.status = :status&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="n">Page</span><span class="o">&lt;</span><span class="n">Long</span><span class="o">&gt;</span><span class="w"> </span><span class="nf">findIdsByStatus</span><span class="p">(</span><span class="n">OrderStatus</span><span class="w"> </span><span class="n">status</span><span class="p">,</span><span class="w"> </span><span class="n">Pageable</span><span class="w"> </span><span class="n">pageable</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nd">@Query</span><span class="p">(</span><span class="s">&#34;select o from Order o join fetch o.items where o.id in :ids&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="n">List</span><span class="o">&lt;</span><span class="n">Order</span><span class="o">&gt;</span><span class="w"> </span><span class="nf">findWithItemsByIdIn</span><span class="p">(</span><span class="n">Collection</span><span class="o">&lt;</span><span class="n">Long</span><span class="o">&gt;</span><span class="w"> </span><span class="n">ids</span><span class="p">);</span><span class="w">
</span></span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="nd">@Transactional</span><span class="p">(</span><span class="n">readOnly</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">true</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kd">public</span><span class="w"> </span><span class="n">Page</span><span class="o">&lt;</span><span class="n">Order</span><span class="o">&gt;</span><span class="w"> </span><span class="nf">findOrders</span><span class="p">(</span><span class="n">OrderStatus</span><span class="w"> </span><span class="n">status</span><span class="p">,</span><span class="w"> </span><span class="n">Pageable</span><span class="w"> </span><span class="n">pageable</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">Page</span><span class="o">&lt;</span><span class="n">Long</span><span class="o">&gt;</span><span class="w"> </span><span class="n">ids</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">orderRepository</span><span class="p">.</span><span class="na">findIdsByStatus</span><span class="p">(</span><span class="n">status</span><span class="p">,</span><span class="w"> </span><span class="n">pageable</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">Map</span><span class="o">&lt;</span><span class="n">Long</span><span class="p">,</span><span class="w"> </span><span class="n">Order</span><span class="o">&gt;</span><span class="w"> </span><span class="n">byId</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">orderRepository</span><span class="p">.</span><span class="na">findWithItemsByIdIn</span><span class="p">(</span><span class="n">ids</span><span class="p">.</span><span class="na">getContent</span><span class="p">()).</span><span class="na">stream</span><span class="p">()</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="p">.</span><span class="na">collect</span><span class="p">(</span><span class="n">Collectors</span><span class="p">.</span><span class="na">toMap</span><span class="p">(</span><span class="n">Order</span><span class="p">::</span><span class="n">getId</span><span class="p">,</span><span class="w"> </span><span class="n">Function</span><span class="p">.</span><span class="na">identity</span><span class="p">()));</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="n">ids</span><span class="p">.</span><span class="na">map</span><span class="p">(</span><span class="n">byId</span><span class="p">::</span><span class="n">get</span><span class="p">);</span><span class="w">   </span><span class="c1">// keeps the sort order of the ID page</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>Now the database does the paging:</p>
<pre tabindex="0"><code>select o1_0.id from orders o1_0 where o1_0.status=? order by o1_0.created_at desc offset ? rows fetch first ? rows only
select count(o1_0.id) from orders o1_0 where o1_0.status=?
select o1_0.id,...,i1_0.quantity from orders o1_0 join order_items i1_0 on o1_0.id=i1_0.order_id where o1_0.id in (?,?,?,...)
</code></pre><p>Three queries and about 200 rows instead of 500,000. Since Hibernate 6, duplicate roots from a fetch join are removed automatically, so you don&rsquo;t need <code>distinct</code> in the second query.</p>
<p>The other option is to not fetch-join the collection at all. Page the orders normally and let batch fetching load the items for the whole page in one <code>IN</code> query:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="nd">@BatchSize</span><span class="p">(</span><span class="n">size</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">50</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nd">@OneToMany</span><span class="p">(</span><span class="n">mappedBy</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&#34;order&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kd">private</span><span class="w"> </span><span class="n">List</span><span class="o">&lt;</span><span class="n">OrderItem</span><span class="o">&gt;</span><span class="w"> </span><span class="n">items</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">new</span><span class="w"> </span><span class="n">ArrayList</span><span class="o">&lt;&gt;</span><span class="p">();</span><span class="w">
</span></span></span></code></pre></div><p>Or globally with <code>hibernate.default_batch_fetch_size</code>, which I covered in the <a href="/posts/5-postgresql-mistakes-i-see-in-every-spring-boot-app/">PostgreSQL article</a>.</p>
<p><strong>How to check your app:</strong> make Hibernate fail instead of warn, at least in tests:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-properties" data-lang="properties"><span class="line"><span class="cl"><span class="na">spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch</span><span class="o">=</span><span class="s">true</span>
</span></span></code></pre></div><p>And search your production logs for <code>HHH90003004</code> today. If it&rsquo;s there, it&rsquo;s been there for a while.</p>
<p><strong>Read more:</strong> <a href="https://docs.hibernate.org/orm/7.0/userguide/html_single/Hibernate_User_Guide.html#hql-limit-offset">Hibernate ORM 7 User Guide: Limits and offsets</a></p>
<h3 id="mistake-4-deleting-with-derived-deleteby-methods">Mistake 4: Deleting with derived deleteBy&hellip;() methods</h3>
<p><img alt="At 4 AM a bear removes warehouse items one by one with tweezers under a counter reading 87,412 of 120,001, next to an untouched DELETE WHERE button" loading="lazy" src="https://sukachvitally.github.io/posts/5-jpa-hibernate-mistakes-that-quietly-kill-your-spring-boot-performance/image-05.jpg"></p>
<p>I mentioned this one in the <a href="/posts/5-postgresql-mistakes-i-see-in-every-spring-boot-app/">PostgreSQL article</a>, from the database&rsquo;s point of view. Here&rsquo;s what happens before the <code>DELETE</code> even reaches the database.</p>
<p>The orders aggregate, mapped the way most aggregates are:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="nd">@OneToMany</span><span class="p">(</span><span class="n">mappedBy</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&#34;order&#34;</span><span class="p">,</span><span class="w"> </span><span class="n">cascade</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">CascadeType</span><span class="p">.</span><span class="na">ALL</span><span class="p">,</span><span class="w"> </span><span class="n">orphanRemoval</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">true</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kd">private</span><span class="w"> </span><span class="n">List</span><span class="o">&lt;</span><span class="n">OrderItem</span><span class="o">&gt;</span><span class="w"> </span><span class="n">items</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="k">new</span><span class="w"> </span><span class="n">ArrayList</span><span class="o">&lt;&gt;</span><span class="p">();</span><span class="w">
</span></span></span></code></pre></div><p>And a nightly purge that reads like plain English:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">public</span><span class="w"> </span><span class="kd">interface</span> <span class="nc">OrderRepository</span><span class="w"> </span><span class="kd">extends</span><span class="w"> </span><span class="n">JpaRepository</span><span class="o">&lt;</span><span class="n">Order</span><span class="p">,</span><span class="w"> </span><span class="n">Long</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kt">long</span><span class="w"> </span><span class="nf">deleteByCreatedAtBefore</span><span class="p">(</span><span class="n">Instant</span><span class="w"> </span><span class="n">cutoff</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="nd">@Transactional</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kd">public</span><span class="w"> </span><span class="kt">void</span><span class="w"> </span><span class="nf">purgeOrdersOlderThan</span><span class="p">(</span><span class="n">Instant</span><span class="w"> </span><span class="n">cutoff</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">orderRepository</span><span class="p">.</span><span class="na">deleteByCreatedAtBefore</span><span class="p">(</span><span class="n">cutoff</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>Spring Data doesn&rsquo;t turn that into <code>DELETE ... WHERE</code>. It selects the orders, then calls <code>EntityManager.remove()</code> on each one. <code>remove()</code> cascades to <code>items</code>, and to cascade, Hibernate first has to load each collection. Then, at flush, every row gets its own <code>DELETE</code>:</p>
<pre tabindex="0"><code>select o1_0.id,o1_0.created_at,o1_0.customer_id,o1_0.shipping_country,o1_0.status from orders o1_0 where o1_0.created_at&lt;?
select i1_0.order_id,i1_0.id,i1_0.price,i1_0.product_id,i1_0.quantity from order_items i1_0 where i1_0.order_id=?
... 9,999 more
delete from order_items where id=?
... 99,999 more
delete from orders where id=?
... 9,999 more
</code></pre><p>For 10,000 orders with 10 items each, that adds up fast:</p>
<p><img alt="deleteByCreatedAtBefore runs 120,001 statements: 1 select of orders, 10,000 selects of items, 100,000 item deletes and 10,000 order deletes, while two bulk deletes run 2" loading="lazy" src="https://sukachvitally.github.io/posts/5-jpa-hibernate-mistakes-that-quietly-kill-your-spring-boot-performance/diagram-delete.jpg"></p>
<p>Plus 110,000 managed entities in memory, plus every <code>@PreRemove</code> callback, plus an Envers audit row per deleted entity if you use Envers. Setting <code>hibernate.jdbc.batch_size</code> groups the deletes into fewer round trips, but the selects, the memory and the statement count stay the same. And on the Postgres side you now have 110,000 dead tuples to vacuum. <a href="/posts/your-delete-insert-is-burning-postgresqls-cpu-here-s-what-s-actually-happening-on-the-page/">Your DELETE + INSERT Is Burning PostgreSQL&rsquo;s CPU</a> shows what that does on the page.</p>
<p><strong>The fix:</strong> bulk deletes, children first:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">public</span><span class="w"> </span><span class="kd">interface</span> <span class="nc">OrderRepository</span><span class="w"> </span><span class="kd">extends</span><span class="w"> </span><span class="n">JpaRepository</span><span class="o">&lt;</span><span class="n">Order</span><span class="p">,</span><span class="w"> </span><span class="n">Long</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nd">@Modifying</span><span class="p">(</span><span class="n">flushAutomatically</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w"> </span><span class="n">clearAutomatically</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">true</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nd">@Query</span><span class="p">(</span><span class="s">&#34;&#34;&#34;
</span></span></span><span class="line"><span class="cl"><span class="s">        delete from OrderItem i
</span></span></span><span class="line"><span class="cl"><span class="s">        where i.order.id in (select o.id from Order o where o.createdAt &lt; :cutoff)
</span></span></span><span class="line"><span class="cl"><span class="s">        &#34;&#34;&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kt">int</span><span class="w"> </span><span class="nf">deleteItemsOfOrdersCreatedBefore</span><span class="p">(</span><span class="n">Instant</span><span class="w"> </span><span class="n">cutoff</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nd">@Modifying</span><span class="p">(</span><span class="n">flushAutomatically</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">true</span><span class="p">,</span><span class="w"> </span><span class="n">clearAutomatically</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="kc">true</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="nd">@Query</span><span class="p">(</span><span class="s">&#34;delete from Order o where o.createdAt &lt; :cutoff&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kt">int</span><span class="w"> </span><span class="nf">deleteOrdersCreatedBefore</span><span class="p">(</span><span class="n">Instant</span><span class="w"> </span><span class="n">cutoff</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="nd">@Transactional</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kd">public</span><span class="w"> </span><span class="kt">int</span><span class="w"> </span><span class="nf">purgeOrdersOlderThan</span><span class="p">(</span><span class="n">Instant</span><span class="w"> </span><span class="n">cutoff</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">orderRepository</span><span class="p">.</span><span class="na">deleteItemsOfOrdersCreatedBefore</span><span class="p">(</span><span class="n">cutoff</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="n">orderRepository</span><span class="p">.</span><span class="na">deleteOrdersCreatedBefore</span><span class="p">(</span><span class="n">cutoff</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>Two statements. But a bulk delete is fast precisely because it skips everything JPA normally does, so know what you&rsquo;re giving up:</p>
<ul>
<li><strong>No cascades.</strong> Nothing else gets deleted for you. If <code>payments</code> references <code>orders</code>, you get a foreign key violation, or orphans if there&rsquo;s no FK. Delete in the right order yourself, or let the database cascade: <code>ON DELETE CASCADE</code> on the FK plus <code>@OnDelete(action = OnDeleteAction.CASCADE)</code> on <code>OrderItem.order</code>, and then you only delete orders.</li>
<li><strong>No lifecycle callbacks.</strong> <code>@PreRemove</code>, entity listeners and Envers don&rsquo;t run. In regulated domains, where the audit trail is a compliance requirement, check with whoever owns that requirement before you switch.</li>
<li><strong>No persistence context.</strong> Entities already loaded in this session don&rsquo;t know they were deleted. <code>clearAutomatically = true</code> clears the context after the delete, and <code>flushAutomatically = true</code> writes pending changes first, so the clear doesn&rsquo;t throw them away.</li>
</ul>
<p>For millions of rows, delete in chunks instead of one giant statement. The PostgreSQL article has the pattern.</p>
<p><strong>How to check your app:</strong> <code>grep -rn -E &quot;(delete|remove)(All)?By|deleteAll\(&quot; src/main/java</code>, then run the statistics test from Mistake 2 around your purge with 3 orders of 2 items each. If <code>getPrepareStatementCount()</code> says 13 instead of 2, you&rsquo;ve found it.</p>
<p><strong>Read more:</strong> <a href="https://docs.spring.io/spring-data/jpa/reference/jpa/query-methods.html#jpa.modifying-queries.derived-delete">Spring Data JPA: Derived delete queries</a></p>
<h3 id="mistake-5-loading-managed-entities-just-to-read-them">Mistake 5: Loading managed entities just to read them</h3>
<p><img alt="A bear takes a polaroid of every box on a conveyor belt to CSV export, until the warehouse is buried in photos and the heap gauge hits 98%" loading="lazy" src="https://sukachvitally.github.io/posts/5-jpa-hibernate-mistakes-that-quietly-kill-your-spring-boot-performance/image-06.jpg"></p>
<p>A nightly export: one day of orders to CSV. Streaming, so it must be memory-safe, right?</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">public</span><span class="w"> </span><span class="kd">interface</span> <span class="nc">OrderRepository</span><span class="w"> </span><span class="kd">extends</span><span class="w"> </span><span class="n">JpaRepository</span><span class="o">&lt;</span><span class="n">Order</span><span class="p">,</span><span class="w"> </span><span class="n">Long</span><span class="o">&gt;</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">Stream</span><span class="o">&lt;</span><span class="n">Order</span><span class="o">&gt;</span><span class="w"> </span><span class="nf">streamByCreatedAtBetween</span><span class="p">(</span><span class="n">Instant</span><span class="w"> </span><span class="n">from</span><span class="p">,</span><span class="w"> </span><span class="n">Instant</span><span class="w"> </span><span class="n">to</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="n">Stream</span><span class="o">&lt;</span><span class="n">Order</span><span class="o">&gt;</span><span class="w"> </span><span class="nf">streamByCreatedAtBefore</span><span class="p">(</span><span class="n">Instant</span><span class="w"> </span><span class="n">cutoff</span><span class="p">);</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="nd">@Transactional</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kd">public</span><span class="w"> </span><span class="kt">void</span><span class="w"> </span><span class="nf">exportDay</span><span class="p">(</span><span class="n">Instant</span><span class="w"> </span><span class="n">from</span><span class="p">,</span><span class="w"> </span><span class="n">Instant</span><span class="w"> </span><span class="n">to</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">try</span><span class="w"> </span><span class="p">(</span><span class="n">Stream</span><span class="o">&lt;</span><span class="n">Order</span><span class="o">&gt;</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">orderRepository</span><span class="p">.</span><span class="na">streamByCreatedAtBetween</span><span class="p">(</span><span class="n">from</span><span class="p">,</span><span class="w"> </span><span class="n">to</span><span class="p">))</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="n">orders</span><span class="p">.</span><span class="na">forEach</span><span class="p">(</span><span class="n">order</span><span class="w"> </span><span class="o">-&gt;</span><span class="w"> </span><span class="n">csv</span><span class="p">.</span><span class="na">write</span><span class="p">(</span><span class="n">order</span><span class="p">.</span><span class="na">getId</span><span class="p">(),</span><span class="w"> </span><span class="n">order</span><span class="p">.</span><span class="na">getStatus</span><span class="p">(),</span><span class="w"> </span><span class="n">order</span><span class="p">.</span><span class="na">getCreatedAt</span><span class="p">()));</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>Every <code>Order</code> you load is a managed entity. For each one, Hibernate keeps the entity itself, an entry in the persistence context, and a snapshot: a copy of every field&rsquo;s value at load time, used for dirty checking. The stream doesn&rsquo;t change that. Entities stay in the persistence context until the transaction ends, whether you still hold a reference or not.</p>
<p>So for 1,000,000 orders you hold <strong>2,000,000 objects</strong> (entities plus snapshots) until commit. Then the flush at commit compares every field of every entity against its snapshot, to update nothing. And if the loop runs any JPQL query, Hibernate auto-flushes before it, which means dirty-checking everything loaded so far. Every time. That&rsquo;s how a linear job becomes quadratic.</p>
<p><strong>The fix:</strong> read DTOs when you only read.</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="kd">public</span><span class="w"> </span><span class="kd">record</span> <span class="nc">OrderExportRow</span><span class="p">(</span><span class="n">Long</span><span class="w"> </span><span class="n">id</span><span class="p">,</span><span class="w"> </span><span class="n">OrderStatus</span><span class="w"> </span><span class="n">status</span><span class="p">,</span><span class="w"> </span><span class="n">Instant</span><span class="w"> </span><span class="n">createdAt</span><span class="p">)</span><span class="w"> </span><span class="p">{}</span><span class="w">
</span></span></span></code></pre></div><div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="nd">@Query</span><span class="p">(</span><span class="s">&#34;&#34;&#34;
</span></span></span><span class="line"><span class="cl"><span class="s">    select new com.example.orders.OrderExportRow(o.id, o.status, o.createdAt)
</span></span></span><span class="line"><span class="cl"><span class="s">    from Order o
</span></span></span><span class="line"><span class="cl"><span class="s">    where o.createdAt &gt;= :from and o.createdAt &lt; :to
</span></span></span><span class="line"><span class="cl"><span class="s">    &#34;&#34;&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nd">@QueryHints</span><span class="p">(</span><span class="nd">@QueryHint</span><span class="p">(</span><span class="n">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">HibernateHints</span><span class="p">.</span><span class="na">HINT_FETCH_SIZE</span><span class="p">,</span><span class="w"> </span><span class="n">value</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&#34;1000&#34;</span><span class="p">))</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="n">Stream</span><span class="o">&lt;</span><span class="n">OrderExportRow</span><span class="o">&gt;</span><span class="w"> </span><span class="nf">streamExportRows</span><span class="p">(</span><span class="n">Instant</span><span class="w"> </span><span class="n">from</span><span class="p">,</span><span class="w"> </span><span class="n">Instant</span><span class="w"> </span><span class="n">to</span><span class="p">);</span><span class="w">
</span></span></span></code></pre></div><p>No managed entities, no snapshots, no dirty checking. Memory stays flat. The fetch size matters too: without it, the Postgres JDBC driver reads the entire result set into memory before your stream sees the first row. (It only streams inside a transaction, with auto-commit off.)</p>
<p>The other tools, for the cases where you need entities:</p>
<ul>
<li><strong><code>@Transactional(readOnly = true)</code>.</strong> Spring marks the Hibernate session read-only and sets flush mode to manual, so entities load without snapshots and nothing is flushed at commit. They still sit in the persistence context, though. Good for normal read endpoints, not enough on its own for a million rows.</li>
<li><strong><code>flush()</code> + <code>clear()</code> every few hundred entities</strong> when the job modifies what it loads:</li>
</ul>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="nd">@Transactional</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kd">public</span><span class="w"> </span><span class="kt">void</span><span class="w"> </span><span class="nf">recalculateTotals</span><span class="p">(</span><span class="n">Instant</span><span class="w"> </span><span class="n">cutoff</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="kt">int</span><span class="w"> </span><span class="n">processed</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">0</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">try</span><span class="w"> </span><span class="p">(</span><span class="n">Stream</span><span class="o">&lt;</span><span class="n">Order</span><span class="o">&gt;</span><span class="w"> </span><span class="n">orders</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="n">orderRepository</span><span class="p">.</span><span class="na">streamByCreatedAtBefore</span><span class="p">(</span><span class="n">cutoff</span><span class="p">))</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="k">for</span><span class="w"> </span><span class="p">(</span><span class="n">Order</span><span class="w"> </span><span class="n">order</span><span class="w"> </span><span class="p">:</span><span class="w"> </span><span class="p">(</span><span class="n">Iterable</span><span class="o">&lt;</span><span class="n">Order</span><span class="o">&gt;</span><span class="p">)</span><span class="w"> </span><span class="n">orders</span><span class="p">::</span><span class="n">iterator</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="n">order</span><span class="p">.</span><span class="na">recalculateTotal</span><span class="p">();</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="o">++</span><span class="n">processed</span><span class="w"> </span><span class="o">%</span><span class="w"> </span><span class="n">500</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="n">0</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span><span class="n">entityManager</span><span class="p">.</span><span class="na">flush</span><span class="p">();</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">                </span><span class="n">entityManager</span><span class="p">.</span><span class="na">clear</span><span class="p">();</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">            </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">        </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><ul>
<li><strong><code>StatelessSession</code></strong> for pure batch work: no persistence context, no snapshots, no cascades, no lazy loading. You call <code>update()</code> yourself, and it runs outside Spring&rsquo;s transaction management.</li>
</ul>
<p><strong>How to check your app:</strong> run the job in staging with statistics on and look at the session metrics Hibernate logs at the end:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-properties" data-lang="properties"><span class="line"><span class="cl"><span class="na">spring.jpa.properties.hibernate.generate_statistics</span><span class="o">=</span><span class="s">true</span>
</span></span><span class="line"><span class="cl"><span class="na">logging.level.org.hibernate.engine.internal.StatisticalLoggingSessionEventListener</span><span class="o">=</span><span class="s">INFO</span>
</span></span></code></pre></div><p>For the export above, you&rsquo;ll see something like this:</p>
<pre tabindex="0"><code>Session Metrics {
    ...
    1843000000 nanoseconds spent executing 1 flushes (flushing a total of 1000000 entities and 1000000 collections);
}
</code></pre><p>If a read-only job flushes a million entities, it&rsquo;s loading entities it should be reading as DTOs.</p>
<p><strong>Read more:</strong> <a href="https://docs.spring.io/spring-data/jpa/reference/repositories/projections.html">Spring Data JPA: Projections</a></p>
<h3 id="bonus-lomboks-data-on-entities">Bonus: Lombok&rsquo;s @Data on entities</h3>
<p><img alt="A bear types log.debug(order) and an avalanche of boxes bursts out of the laptop, burying the elephant, with a counter showing 51 queries" loading="lazy" src="https://sukachvitally.github.io/posts/5-jpa-hibernate-mistakes-that-quietly-kill-your-spring-boot-performance/image-07.jpg"></p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="nd">@Data</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nd">@Entity</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nd">@Table</span><span class="p">(</span><span class="n">name</span><span class="w"> </span><span class="o">=</span><span class="w"> </span><span class="s">&#34;orders&#34;</span><span class="p">)</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kd">public</span><span class="w"> </span><span class="kd">class</span> <span class="nc">Order</span><span class="w"> </span><span class="p">{</span><span class="w"> </span><span class="cm">/* customer, items, ... */</span><span class="w"> </span><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p>Then, somewhere in a service:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="n">log</span><span class="p">.</span><span class="na">debug</span><span class="p">(</span><span class="s">&#34;Saving order {}&#34;</span><span class="p">,</span><span class="w"> </span><span class="n">order</span><span class="p">);</span><span class="w">
</span></span></span></code></pre></div><p><code>@Data</code> generates a <code>toString()</code> that prints every field. Printing <code>items</code> initializes the lazy collection, and printing each item&rsquo;s lazy <code>product</code> initializes that too. An order with 50 items: <strong>51 queries from one log line</strong>. If <code>OrderItem</code> also has <code>@Data</code>, its <code>toString()</code> prints <code>order</code>, which prints <code>items</code>, which prints <code>order</code>, and you get a <code>StackOverflowError</code>. And all of this only happens when DEBUG is on, which is exactly when you&rsquo;re investigating a production problem.</p>
<p>The generated <code>equals()</code> and <code>hashCode()</code> are just as bad: they use every field, so they touch lazy associations, and the hash code changes when the ID is assigned on persist, which loses the entity inside a <code>HashSet</code>.</p>
<p>Use <code>@Getter</code> and <code>@Setter</code>, and write these three yourself:</p>
<div class="highlight"><pre tabindex="0" class="chroma"><code class="language-java" data-lang="java"><span class="line"><span class="cl"><span class="nd">@Override</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kd">public</span><span class="w"> </span><span class="kt">boolean</span><span class="w"> </span><span class="nf">equals</span><span class="p">(</span><span class="n">Object</span><span class="w"> </span><span class="n">o</span><span class="p">)</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="k">this</span><span class="w"> </span><span class="o">==</span><span class="w"> </span><span class="n">o</span><span class="p">)</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="kc">true</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">if</span><span class="w"> </span><span class="p">(</span><span class="o">!</span><span class="p">(</span><span class="n">o</span><span class="w"> </span><span class="k">instanceof</span><span class="w"> </span><span class="n">Order</span><span class="w"> </span><span class="n">other</span><span class="p">))</span><span class="w"> </span><span class="k">return</span><span class="w"> </span><span class="kc">false</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">!=</span><span class="w"> </span><span class="kc">null</span><span class="w"> </span><span class="o">&amp;&amp;</span><span class="w"> </span><span class="n">id</span><span class="p">.</span><span class="na">equals</span><span class="p">(</span><span class="n">other</span><span class="p">.</span><span class="na">getId</span><span class="p">());</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nd">@Override</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kd">public</span><span class="w"> </span><span class="kt">int</span><span class="w"> </span><span class="nf">hashCode</span><span class="p">()</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="n">getClass</span><span class="p">().</span><span class="na">hashCode</span><span class="p">();</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">}</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="nd">@Override</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="kd">public</span><span class="w"> </span><span class="n">String</span><span class="w"> </span><span class="nf">toString</span><span class="p">()</span><span class="w"> </span><span class="p">{</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w">    </span><span class="k">return</span><span class="w"> </span><span class="s">&#34;Order{id=&#34;</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">id</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="s">&#34;, status=&#34;</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="n">status</span><span class="w"> </span><span class="o">+</span><span class="w"> </span><span class="s">&#34;}&#34;</span><span class="p">;</span><span class="w">
</span></span></span><span class="line"><span class="cl"><span class="w"></span><span class="p">}</span><span class="w">
</span></span></span></code></pre></div><p><strong>Read more:</strong> <a href="https://docs.hibernate.org/orm/7.0/userguide/html_single/Hibernate_User_Guide.html#mapping-model-pojo-equalshashcode">Hibernate ORM 7 User Guide: Implementing equals() and hashCode()</a></p>
<h3 id="a-quick-checklist">A quick checklist</h3>
<ul>
<li>Is <code>spring.jpa.open-in-view=false</code> set explicitly?</li>
<li>Does every <code>@ManyToOne</code> and <code>@OneToOne</code> say <code>fetch = FetchType.LAZY</code>?</li>
<li>Is <code>HHH90003004</code> anywhere in your production logs?</li>
<li>Any derived <code>deleteBy...()</code> or <code>deleteAll()</code> on tables with more than a few thousand rows?</li>
<li>Do your export and batch jobs read DTOs, or a million managed entities?</li>
<li>Is there a query-count test for your three busiest endpoints?</li>
</ul>
<p>None of these are Hibernate bugs. Every one is a default, or a convenience method, doing exactly what it was designed to do, on data it was never tested with. If you fixed these and want the next layer down, <a href="/posts/5-postgresql-mistakes-i-see-in-every-spring-boot-app/">5 PostgreSQL Mistakes I See in Every Spring Boot App</a> covers what the database does with the queries Hibernate sends, <a href="/posts/when-one-fast-query-becomes-a-production-nightmare-the-dehydration-problem-in-hibernate/">The Dehydration Problem in Hibernate</a> goes deep on hydration cost, and <a href="/posts/5-spring-boot-mistakes-i-see-in-every-codebase/">5 Spring Boot Mistakes I See in Every Codebase</a> covers the transaction boundaries all of this depends on.</p>
]]></content:encoded></item></channel></rss>