<?xml version="1.0" encoding="UTF-8"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:content="http://purl.org/rss/1.0/modules/content/"><channel><title>Bartosz Osipiuk</title><description>Senior Software Engineer. I write about Java, the backend and design decisions made in a running product.</description><link>https://bartoszosipiuk.pl/en/</link><language>en</language><atom:link href="https://bartoszosipiuk.pl/en/rss.xml" rel="self" type="application/rss+xml"/><item><title>Hibernate lost my update</title><link>https://bartoszosipiuk.pl/en/blog/hibernate-lost-my-update/</link><guid isPermaLink="true">https://bartoszosipiuk.pl/en/blog/hibernate-lost-my-update/</guid><description>ACID? Sure, but at the database level, not in the tools on top. How Hibernate makes a partial commit when Spring Boot shuts down – and how to prevent it.</description><pubDate>Thu, 29 Oct 2026 00:00:00 GMT</pubDate><content:encoded><![CDATA[<h2 id="in-short">In short</h2>
<p>There’s a chance that during application shutdown, Hibernate won’t run the last <code>flush()</code> before the commit. When that happens, we end up with a commit that doesn’t contain all our changes, and nothing warns us about it (apart from a log at TRACE level).</p>
<div><div><link href="https://bartoszosipiuk.pl/_astro/ec.zzg5e.css"><figure><figcaption></figcaption><pre><code>@Transactional
public void markPaid(UUID id) {
    var order = repo.findById(id).orElseThrow();
    order.setStatus(PAID);  // waits in memory for the flush at commit
    longOperation.run();    // slow call: SIGTERM arrives, Spring closes the SessionFactory
}                           // commit: flush skipped, no exception, the UPDATE is never sent</code></pre></figure></div></div>
<p>How I found it and why it happens are below. If you only want the fix, skip to <a href="https://bartoszosipiuk.pl/en/blog/hibernate-lost-my-update/#how-to-protect-yourself">section <span>1.9</span></a>.</p>
<h2 id="events-that-wont-go-through">Events that won’t go through</h2>
<p>First task at a new job: “find out why the events in the queue can’t be processed”. A great first task to get to know the company’s tools. The code in short: in one transaction we mark a parent record and its records as deleted (soft delete) + write a <a href="https://microservices.io/patterns/data/transactional-outbox.html">transactional <span>outbox<span> (opens in a new tab)</span></span></a> with an event for every deleted record.</p>
<p>OK, so I check the logs and monitoring. First suspect – a bug in the code, or a race condition thanks to Read Committed isolation. Nope: the code looked 100% fine and hadn’t changed since that event showed up.</p>
<h2 id="the-pods-last-logs">The pod’s last logs</h2>
<p>I noticed that the logs from that operation were among the last ones on the pod, because autoscaling was just stopping it. But that wasn’t the most interesting part. Another service had read the event about a record being deleted, and on our side that record wasn’t deleted at all…
Interestingly, <strong>the last event of that transaction was about exactly the record whose change disappeared</strong>.</p>
<p>Since the code looked fine, I asked the database. Deleting is just a <code>deleted</code> flag for us, so the rows were still there. I learned that every row in Postgres has a hidden <code>xmin</code> column: the id of the transaction that wrote its current version. <code>age(xmin)</code> tells you how many transactions ago that was.</p>
<div><figure><figcaption></figcaption><pre><code>SELECT id, deleted, age(xmin) AS transactions_ago
FROM record
WHERE parent_id = 42;</code></pre></figure></div>
<p>The parent record and the deleted records – a fresh <code>xmin</code>, from the incident’s transaction. The record that wasn’t deleted – an <code>xmin</code> from hundreds of millions of transactions ago. So nobody had touched that row in a very long time. Which means the <code>UPDATE</code> wasn’t overwritten by another transaction – Postgres simply never got it. But in that same transaction it did get, and commit, the outbox row with the event about deleting that record.</p>
<p>“Connecting the dots” and ruling out a bug in the code or in the database, I started to suspect Hibernate. With some help from AI I found an interesting bit in <code>beforeTransactionCompletion()</code>. But one thing at a time – first: when does Hibernate send changes to the database at all?</p>
<h2 id="how-hibernate-writes-your-changes">How Hibernate writes your changes</h2>
<p>If you know how the persistence context works and when Hibernate flushes, you can skip this one – next up is <a href="https://bartoszosipiuk.pl/en/blog/hibernate-lost-my-update/#when-hibernate-skips-the-flush-at-commit">section <span>1.5</span></a>.</p>
<p>In a nutshell – Hibernate (when it can) keeps changes to entities in the “persistence context” and sends them to the database only at a flush. A flush runs before a query that might read those changes, and always right before the commit (or so I thought).</p>
<p>Not everything waits for the flush, though. These go to the database right away<sup><a href="https://bartoszosipiuk.pl/en/blog/hibernate-lost-my-update/#sn-1">1</a></sup>:</p>
<aside id="sn-1"><span>1</span> Assuming Hibernate’s default flush mode, <code>AUTO</code>.</aside>
<ul>
<li>the <code>INSERT</code> of an entity with an <code>IDENTITY</code> id, because the database assigns the id</li>
<li>hand-written update/delete in JPQL or native SQL</li>
<li>anything that goes through plain JDBC</li>
<li>changes already sent by an earlier flush</li>
</ul>
<p>Everything else – changed entity fields, <code>remove()</code>, new entities with a sequence id – waits in memory for the flush. So if the flush at commit didn’t run, the database would end up with only what had been sent before.</p>
<p>For each record, our code looked roughly like this:</p>
<div><div><figure><figcaption></figcaption><pre><code>Record record = recordRepository.findActiveById(recordId);
record.setDeleted(true);                       // the UPDATE waits in memory for the flush
outbox.save(new RecordDeletedEvent(recordId)); // JDBC, sent right away</code></pre></figure></div></div>
<p>If the flush at commit doesn’t run, the event is saved and <code>deleted</code> stays <code>false</code>.</p>
<p>Why was it the <strong>last</strong> record that got lost? <code>findActiveById()</code> is a query on the <code>record</code> table, and Hibernate also flushes before a query that reads a table with something waiting to be written. So each next record pushed the previous record’s <code>UPDATE</code> out to the database, and at commit only the last one was still waiting in memory. Exactly what <code>xmin</code> showed.</p>
<aside>
<p>Note</p>
<p><code>findById()</code> works differently: under the hood it’s <code>em.find()</code>, i.e. loading an entity by id, and that never flushes – even when it goes to the database for a <code>SELECT</code>. If we had used <code>findById()</code>, the commit would have lost the changes to <strong>all</strong> the records, not just the last one.</p>
</aside>
<h2 id="when-hibernate-skips-the-flush-at-commit">When Hibernate skips the flush at commit</h2>
<p>This is where <code>beforeTransactionCompletion()</code> comes back. At commit it calls <code>managedFlush()</code>, which checks whether the session is open. Here’s what that looks like in Hibernate 7.4.5:</p>
<div id="lst-sessionimpl-java"><div><figure><figcaption><span>SessionImpl.java</span></figcaption><pre><code>private void managedFlush() {
    if ( !isOpenOrWaitingForAutoClose() ) {
        SESSION_LOGGER.skippingAutoFlushSessionClosed(); // TRACE
    }
    else {
        SESSION_LOGGER.automaticallyFlushingSession();
        fireFlush();
    }
}


// inherited from AbstractSharedSessionContract
public boolean isOpenOrWaitingForAutoClose() {
    return !closed &#x26;&#x26; factory.isOpen()
        || waitingForAutoClose;
}</code></pre></figure></div></div>
<p>When the factory is closed, every one of its sessions counts as closed, so <code>managedFlush()</code> logs <code>Skipping auto-flush since the session is closed</code> at TRACE<sup><a href="https://bartoszosipiuk.pl/en/blog/hibernate-lost-my-update/#sn-2">2</a></sup> and returns, and <code>Connection.commit()</code> goes to Postgres, which commits everything it got. Even worse – nothing throws, the only trace is that one log line at TRACE level (!).</p>
<aside id="sn-2"><span>2</span> Hibernate 6.6 has the same check and logs <code>Skipping auto-flush due to session closed</code>.</aside>
<p>You don’t need Spring to reproduce it, or even a second thread. In the test below <code>Payment</code> has an <code>IDENTITY</code> id, so its <code>INSERT</code> goes out at <code>persist()</code>, while the order’s new status waits for the flush. We close the factory, commit and… the payment is there, and the order is still <code>NEW</code>.</p>
<div id="lst-sessionfactoryclosedbeforecommittest-java"><div><figure><figcaption><span>SessionFactoryClosedBeforeCommitTest.java</span><a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/16d7340404b8e1883d37a7c8caac5a7df6a809fc/src/test/java/plainhibernate/SessionFactoryClosedBeforeCommitTest.java"><span>GitHub</span><span> — SessionFactoryClosedBeforeCommitTest.java in the repository, opens in a new tab</span></a></figcaption><pre><code>@Test
void closeAndCommitOnTheSameThreadAlsoSaveOnlyPartOfTheTransaction() throws Exception {
    Session session = sessionFactory.openSession();
    Transaction transaction = session.beginTransaction();
    Payment payment = new Payment();
    payment.orderId = 1L;
    session.persist(payment);
    session.find(Order.class, 1L).status = "PAID";


    sessionFactory.close();
    transaction.commit();
    session.close();


    assertEquals(1, count("select count(*) from payments where orderId = 1"), "the INSERT was committed");
    assertEquals("NEW", status(), "the UPDATE was never executed, yet the transaction was committed");
}</code></pre></figure></div></div>
<p>I reproduced it on Hibernate 6.6.53, 6.6.59, 7.4.5 and 7.4.13 with PostgreSQL, and on 7.4.13 and 8.0.0.Beta3 with H2 (on Hibernate’s test case template). I didn’t check versions older than 6.6.</p>
<p>But who closes the factory in the middle of a transaction?</p>
<h2 id="what-happens-on-sigterm">What happens on SIGTERM</h2>
<p>Remember that autoscaling was just stopping the pod? On <code>SIGTERM</code>, Spring Boot shuts down in two stages. First (the SmartLifecycle phase) it stops whatever can create new work: HTTP endpoints, listeners/consumers etc. + gives them a limited time to finish (by default: the Kafka container waits 10 s, RabbitMQ 5 s, and each shutdown phase in Spring Boot at most 30 s). Then it destroys beans, our <code>EntityManagerFactory</code> among them, and finally the <code>DataSource</code>.</p>
<p>The problem is that the second stage assumes the first one finished everything, and a handler waiting for a slow external API <strong>may not make it in time</strong>. Spring moves on to the next phase and starts destroying beans even though the thread is still running. The thread finally gets its response, leaves the <code>@Transactional</code> method, and Hibernate commits without a flush, because the <code>EntityManagerFactory</code> is already closed.</p>
<p>I’ll show it on a <a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown">demo <span>application<span> (GitHub, opens in a new tab)</span></span></a>: a small shop where a single <code>@Transactional</code> method loads an order, saves a payment, marks the order as paid and runs a long operation (yes, normally we’d split this into several messages, but it’s a good example to show the problem with Hibernate). Below is that transaction step by step – in step 4, while the thread waits, Spring closes the factory. Leave the guard switch (more on it later) off for now.</p>
<p><a href="https://bartoszosipiuk.pl/en/blog/hibernate-lost-my-update/#demo-what-reaches-postgres-right-away-and-what-waits-in-hibernate-s-memory">Interactive demo in the post: What reaches Postgres right away, and what waits in Hibernate’s memory</a></p>
<p>And here’s how it plays out in time – the worker thread and Spring shutting down, side by side:</p>
<p><a href="https://bartoszosipiuk.pl/en/blog/hibernate-lost-my-update/#demo-a-worker-thread-e-g-a-kafka-consumer-when-sigterm-arrives">Interactive demo in the post: A worker thread (e.g. a Kafka consumer) when SIGTERM arrives</a></p>
<p>Funnily enough, with <code>SIGKILL</code> the connection drops and Postgres rolls back. A partial commit needs exactly the “polite” (<code>SIGTERM</code>) shutdown.</p>
<h2 id="is-it-just-our-app">Is it just our app?</h2>
<p>At first I thought it was a configuration issue in our service, so <a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown">I built a clean Spring Boot <span>app<span> (GitHub, opens in a new tab)</span></span></a> + Spring JPA + Spring Kafka/AMQP. The result – surprising. I reproduced the bug there too, which means it can hit any application (with a bit of [bad] luck).</p>
<p>The repo is the same shop as in the demos above: Spring Boot 4 + Hibernate 7 + Postgres, with Spring MVC, Kafka and RabbitMQ. Each test sends a payment in through a different entry point, stops the pod in the middle of the long operation, and finally looks into the database to verify the result.<sup><a href="https://bartoszosipiuk.pl/en/blog/hibernate-lost-my-update/#sn-3">3</a></sup></p>
<aside id="sn-3"><span>3</span> The demo runs with <code>spring.jpa.open-in-view=false</code>, which isn’t Spring Boot’s default. OSIV only covers web requests, so it doesn’t affect the listener, <code>@Async</code> or <code>@Scheduled</code> rows. I haven’t tested the HTTP row with OSIV on, but I’d expect the same result, because <code>managedFlush()</code> also checks whether the factory is open.</aside>
<p>One thing is deliberately “rigged”: a <code>MetricsExporter</code> bean takes 0.5 s to close and is wired so that Spring destroys it between Hibernate and the pool. That stretches the window (more on it in the next section) from ~1.2 ms to half a second, so the tests hit it every time and are deterministic. Everything else – Spring, Hibernate, HikariCP, the listener containers – runs unchanged. The test that measures the window itself works without this trick – we have that one too :)</p>
<p>Results (the “With the guard” column will make sense two sections from now):</p>








































<div><table><thead><tr><th>Scenario</th><th>Without the guard</th><th>With the guard</th></tr></thead><tbody><tr><td><a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/16d7340404b8e1883d37a7c8caac5a7df6a809fc/src/test/java/shop/KafkaPaymentShutdownTest.java"><span><code>@KafkaListener</code><span> (GitHub, opens in a new tab)</span></span></a></td><td>payment saved, order <code>NEW</code>, success reported, no error logged</td><td>rolled back, a new pod processes the message in full</td></tr><tr><td><a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/16d7340404b8e1883d37a7c8caac5a7df6a809fc/src/test/java/shop/RabbitMqPaymentShutdownTest.java"><span><code>@RabbitListener</code><span> (GitHub, opens in a new tab)</span></span></a></td><td>payment saved, order <code>NEW</code>, message counted as processed</td><td>rolled back, the message returns to the queue</td></tr><tr><td><a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/16d7340404b8e1883d37a7c8caac5a7df6a809fc/src/test/java/shop/HttpRequestShutdownTest.java">HTTP <span>request<span> (GitHub, opens in a new tab)</span></span></a></td><td>payment saved, order <code>NEW</code>, no response to the caller</td><td>nothing saved, the caller can safely retry</td></tr><tr><td><a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/16d7340404b8e1883d37a7c8caac5a7df6a809fc/src/test/java/shop/AsyncTaskShutdownTest.java"><code>@Async</code> <span>task<span> (GitHub, opens in a new tab)</span></span></a></td><td>payment saved, order <code>NEW</code>, the provider already got <code>202</code></td><td>nothing half-saved, but the provider won’t resend</td></tr><tr><td><a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/16d7340404b8e1883d37a7c8caac5a7df6a809fc/src/test/java/shop/ScheduledJobShutdownTest.java"><code>@Scheduled</code> <span>job<span> (GitHub, opens in a new tab)</span></span></a></td><td>payment saved, order <code>NEW</code>, no error logged</td><td>rolled back, nothing saved</td></tr><tr><td><a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/16d7340404b8e1883d37a7c8caac5a7df6a809fc/src/test/java/shop/PaymentShutdownStory.java">Postgres’s own log and <span><code>xmin</code><span> (GitHub, opens in a new tab)</span></span></a>, Kafka and RabbitMQ</td><td><code>INSERT</code> and <code>COMMIT</code> received, the <code>UPDATE</code> never</td><td><code>ROLLBACK</code> instead of <code>COMMIT</code></td></tr></tbody></table></div>
<p>To run the tests you need Docker and Java 21+:</p>
<div id="lst-terminal"><div><figure><figcaption><span>Terminal</span></figcaption><pre><code>$ git clone https://github.com/Mixonn/hibernate-partial-commit-on-shutdown.git
$ cd hibernate-partial-commit-on-shutdown
$ mvn -B test -Dtest=KafkaPaymentShutdownTest</code></pre></figure></div></div>
<h2 id="the-12-ms-window">The 1.2 ms window</h2>
<p>There’s one condition. The commit has to land after Hibernate is closed, but before the connection pool is. Once the pool has closed the connection, the commit fails and Postgres rolls back.</p>
<p>In a plain Spring Boot 4.1 app with HikariCP that gap is about 1.2 ms (<a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/16d7340404b8e1883d37a7c8caac5a7df6a809fc/src/test/java/shop/window/ShutdownWindowMeasurement.java#L40">see for <span>yourself<span> (GitHub, opens in a new tab)</span></span></a>), measured on a laptop with a local database<sup><a href="https://bartoszosipiuk.pl/en/blog/hibernate-lost-my-update/#sn-4">4</a></sup>. If the <code>COMMIT</code> goes out in the first ~0.5 ms of the window, it ends as a silent partial commit. Later on, the <code>COMMIT</code> still reaches Postgres, but HikariCP closes the socket before the response arrives, and the app gets <code>Socket closed</code>. So: data saved halfway + an error that invites you to retry something that’s already half done. You can drag the commit in the demo below and see what happens where.</p>
<aside id="sn-4"><span>4</span> 160 shutdowns, each in a fresh JVM, with Hibernate 7.4.5, HikariCP 7.0.2 and Postgres 16 in Docker. The median was 1.17 ms, the longest 5 ms.</aside>
<p><a href="https://bartoszosipiuk.pl/en/blog/hibernate-lost-my-update/#demo-1-2-ms-between-closing-hibernate-and-closing-the-pool">Interactive demo in the post: 1.2 ms between closing Hibernate and closing the pool</a></p>
<p>Doesn’t sound like much, but pods get stopped all the time, and the window can get longer with limited CPU, a slow network etc.</p>
<p>Why does nobody notice? First, with short transactions it happens extremely rarely. Second, even when it does happen, the business code sees a successful commit, and in the second part of the window – a connection error while the pod shuts down (i.e. something “normal”). The only log that tells the truth is at TRACE level. In other words, there is none.</p>
<p>What’s left is inconsistent data. We only noticed ours thanks to that event on the DLT. Without it the row would still be sitting there, and any discrepancies would have been written off as “cannot reproduce” or “the code probably had a bug once, now everything looks fine”.</p>
<p>Our case wasn’t critical, but what about when money is involved, or critical business rules for important customers? To me this is unacceptable behaviour from Hibernate.</p>
<h2 id="how-to-protect-yourself">How to protect yourself</h2>
<p>The workaround is a guard that refuses the commit when the factory is already closed. In every transaction it registers a <code>Synchronization</code>, which Hibernate calls after the flush (or after skipping it) and before <code>Connection.commit()</code>. If the factory is closed by then, the guard throws and Hibernate rolls back.</p>
<p>In Spring the easiest way is to make it a <code>TransactionExecutionListener</code> – a single <code>@Component</code> is enough: Spring Boot attaches such beans to the transaction manager on its own, and <code>afterBegin</code> runs at the start of every transaction. The full code is in <a href="https://bartoszosipiuk.pl/en/blog/hibernate-lost-my-update/#lst-partialcommitguard-java">listing <span>1.4</span></a>.</p>
<div id="lst-partialcommitguard-java"><div><figure><figcaption><span>PartialCommitGuard.java</span><a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/17f45627c180e969c29336f45bc95308737f425b/src/main/java/fix/PartialCommitGuard.java"><span>GitHub</span><span> — PartialCommitGuard.java in the repository, opens in a new tab</span></a></figcaption><pre><code>@Component
public class PartialCommitGuard implements TransactionExecutionListener {


    private static final Logger log = LoggerFactory.getLogger(PartialCommitGuard.class);


    /** Called at the start of every transaction. */
    @Override
    public void afterBegin(TransactionExecution transaction, Throwable beginFailure) {
        if (beginFailure != null || !transaction.isNewTransaction()) {
            return;
        }
        try {
            for (Object resource : TransactionSynchronizationManager.getResourceMap().values()) {
                if (resource instanceof EntityManagerHolder holder) {
                    Session session = holder.getEntityManager().unwrap(Session.class);
                    session.getTransaction().registerSynchronization(new Guard(session));
                }
            }
        } catch (RuntimeException e) {
            // Never throw from here: it would break the start of the transaction.
            log.warn("Could not add the guard to the transaction", e);
        }
    }


    /** The guard. Hibernate calls beforeCompletion() right before committing the transaction. */
    static final class Guard implements Synchronization {


        private final Session session;


        Guard(Session session) {
            this.session = session;
        }


        @Override
        public void beforeCompletion() {
            if (session.getSessionFactory().isClosed() &#x26;&#x26; !readOnly()) {
                throw new HibernateClosedBeforeCommitException();
            }
        }


        @Override
        public void afterCompletion(int status) {
        }


        /** In read-only transactions Hibernate writes nothing anyway, so there is nothing to guard. */
        private boolean readOnly() {
            try {
                return session.getHibernateFlushMode() == FlushMode.MANUAL;
            } catch (RuntimeException e) {
                return false;
            }
        }
    }


    public static final class HibernateClosedBeforeCommitException extends IllegalStateException {


        HibernateClosedBeforeCommitException() {
            super("Hibernate was closed before the transaction was committed: rolling it back instead of saving only part of the data");
        }
    }
}</code></pre></figure></div></div>
<p>The diagram below shows exactly where the guard steps into the commit.</p>
<p><a href="https://bartoszosipiuk.pl/en/blog/hibernate-lost-my-update/#fig-inside-the-commit-without-and-with-the-guard">Figure in the post: Inside the commit, without and with the guard</a></p>
<p>I also ran a test that closed the factory at different moments around the commit – 1000 runs. Without the guard 2–4% of transactions were saved partially, with the guard none.</p>
<p>A closed factory never opens again, and the guard runs after the flush decision has been made, so a skipped flush always hits it. It can be wrong the other way round: if the factory closes right after a full flush, the guard rolls back a transaction that was fine.</p>
<p>It’s your call<sup><a href="https://bartoszosipiuk.pl/en/blog/hibernate-lost-my-update/#sn-5">5</a></sup> – would you rather have a partially saved transaction, or a rollback of one that could still have made it right before the pod went down?</p>
<aside id="sn-5"><span>5</span> It’s not really your call – the decision has been made: rollback :) A rolled-back transaction can be safely retried (the message goes back to the queue, the HTTP client retries the request), while a partial commit nobody will notice and nobody will fix.</aside>
<aside>
<p>Caution</p>
<p>The guard doesn’t cover JTA or manually created entity managers.</p>
</aside>
<p>You can also go back to the demo in the SIGTERM section and turn the guard on.</p>
<h2 id="where-it-came-from-and-whats-next">Where it came from, and what’s next</h2>
<p>In 2013 there was an issue, <a href="https://hibernate.atlassian.net/browse/HHH-8586"><span>HHH-8586<span> (opens in a new tab)</span></span></a>: a JTA commit after <code>EntityManagerFactory.close()</code> triggered a flush, which threw. The fix (<a href="https://github.com/hibernate/hibernate-orm/commit/fa6a222ab1dd9925d95f211f38c951e0db4095ad"><span><code>fa6a222</code><span> (GitHub, opens in a new tab)</span></span></a>, version 4.3.0.Beta5) changed <code>isClosed()</code> to <code>closed || factory.isClosed()</code>, and ever since then a flush after the factory is closed is silently (yes, TRACE log) skipped instead of ending with an exception and a rollback. The tests for that fix had nothing to flush, so there was nothing to “lose” either, and nobody mentions it in the issue. So my bet is that the data loss is an oversight, not anyone’s decision.</p>
<p>Interestingly, almost every other call on the session after the factory is closed goes through <code>checkOpen()</code>. If there had been one more <code>persist()</code> on the way to the commit, you’d get <code>IllegalStateException: Session/EntityManager is closed</code> and a clean rollback. Only the flush at commit silently skips its job and carries on.</p>
<p>What do Hibernate’s own docs say? The <code>SessionFactory.close()</code> javadoc makes it the application’s responsibility to ensure no sessions are open, because “the impact on those sessions is indeterminate”. So you could call this the application’s fault, and that’s a reasonable objection. But here it’s Spring’s shutdown that closes the factory, not our code, while a slow thread is still running. I also don’t expect those sessions to keep working – I’d just expect the commit to fail, the way the other calls do.</p>
<p>And what does JPA say? The spec (Jakarta Persistence 3.2) doesn’t cover this case directly: <code>EntityManagerFactory.close()</code> says that once the factory is closed, all its entity managers are closed, and <code>EntityTransaction.commit()</code> has “writing any unflushed changes to the database” and a <code>RollbackException</code> for when the commit fails. To me, a commit that can’t write the changes is a failed commit. Hibernate fills that gap – just on the side where data gets lost.</p>
<p>I reported it as <a href="https://hibernate.atlassian.net/browse/HHH-21038"><span>HHH-21038<span> (opens in a new tab)</span></span></a> (Improvement, October 22, 2026); at the time of writing it’s still New. I proposed that <code>managedFlush()</code> throw if the session may still have changes (queued actions or managed entities), and keep the silent commit only when there’s nothing to flush – i.e. the HHH-8586 case. If that doesn’t fly, then at least log at WARN/ERROR instead of TRACE.</p>
<p>Both reproducers are public: <a href="https://github.com/Mixonn/hibernate-test-case-templates/tree/partial-commit-after-sessionfactory-close">with H2 on Hibernate’s test case <span>template<span> (GitHub, opens in a new tab)</span></span></a> (commit <code>96be59a</code>) and <a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/16d7340404b8e1883d37a7c8caac5a7df6a809fc/src/test/java/plainhibernate/SessionFactoryClosedBeforeCommitTest.java">with <span>PostgreSQL<span> (GitHub, opens in a new tab)</span></span></a>.</p>
<h2 id="what-should-i-do-now">What should I do now?</h2>
<p>Don’t panic. This only happens when some thread runs longer than SmartLifecycle allows, and the time window in a plain app is about 1.2 ms (in production services probably longer).</p>
<p>Short transactions help, but people make mistakes. Someone adds a call to another service “just for a moment”, someone doesn’t notice that a method further up is <code>@Transactional</code>, a timeout stays at its default (which is often infinite<sup><a href="https://bartoszosipiuk.pl/en/blog/hibernate-lost-my-update/#sn-6">6</a></sup>), or a completely different bug – a deadlock, a slow query, a hung client – holds the thread longer than we assumed. And then we run into this problem anyway. That’s why the guard is still the best protection.</p>
<aside id="sn-6"><span>6</span> For example <code>RestClient</code> and <code>RestTemplate</code> in Spring Boot 4.1: without <code>spring.http.clients.read-timeout</code> they wait for a response with no limit, because Boot’s default HTTP client settings have neither a <code>connectTimeout</code> nor a <code>readTimeout</code>. <code>@Transactional</code> itself has no timeout by default either.</aside>
<p>So what to do?</p>
<ol>
<li>Check in your metrics how long your transactions take – every long one is a potential candidate for this bug.</li>
<li>Move external API calls, HTTP calls to other services and long operations out of your transactions. Where you can’t, set timeouts shorter than the shutdown time.</li>
<li>Add the <a href="https://github.com/Mixonn/hibernate-partial-commit-on-shutdown/blob/17f45627c180e969c29336f45bc95308737f425b/src/main/java/fix/PartialCommitGuard.java"><span>guard<span> (GitHub, opens in a new tab)</span></span></a>. If your transactions run long – I wouldn’t think twice, I’d add it. If they’re short – no rush, but it’s still worth adding rather than risking losing part of a transaction.</li>
<li>Watch <a href="https://hibernate.atlassian.net/browse/HHH-21038"><span>HHH-21038<span> (opens in a new tab)</span></span></a> – if Hibernate fixes it, you can remove the guard.</li>
</ol>
<p><img src="https://bartoszosipiuk.pl/_astro/meme-anakin-padme.C0AuMufT_Z1GEWea.webp" alt="Anakin and Padmé meme. Anakin: &#x22;Commit went through, no errors.&#x22; Padmé, smiling: &#x22;And everything got saved, right?&#x22; Anakin says nothing. Padmé, no longer smiling: &#x22;And everything got saved, right?&#x22;"></p>]]></content:encoded></item></channel></rss>