save, saveAndFlush, and REQUIRES_NEW: The Hibernate Questions Hiding in One Spring Service Method

Last week I was reading a teammate’s PR and stopped on a method that made me realise I’d been writing Spring + Hibernate code for years without actually understanding what save, flush, commit, and REQUIRES_NEW each do.

Here’s the method:

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void addAttachmentInNewTransaction(EmailInbox inbox,
                                          EmailAttachment attachment) {
    attachment.setEmailInbox(inbox);
    attachmentRepository.saveAndFlush(attachment);
}

Eight lines. Two annotations. One method call. And four questions, in the order any developer reading this method should ask them:

  • what saveAndFlush does that save doesn’t,
  • what “flush” even means if you’re already calling save,
  • why REQUIRES_NEW is required for this exact pattern to work,
  • and who, in the end, actually commits the transaction.

If you’ve ever written repository.save(...) and felt fuzzy about what happens between that line and the data actually being in the database, this one’s for you.


1. What does save actually do?

Here’s the thing nobody tells you on day one of Spring Data JPA: save doesn’t insert. It schedules an insert.

When you call repository.save(entity), Hibernate does not send the INSERT SQL to the database right away. It puts the entity in an in-memory bookkeeping structure called the persistence context (sometimes called the first-level cache or session), and then waits. The actual SQL only goes out at a “flush” — which, by default, happens automatically at one of three moments:

  • right before a query that needs to see the pending writes,
  • at the end of the transaction (commit time),
  • or when you explicitly call flush() / saveAndFlush().

So save is less “insert this row” and more “note that this row needs to exist; I’ll send it later.”

This is a feature, not a bug. Hibernate buffers because it lets the framework batch multiple writes into one network round-trip, reorder them for foreign-key constraints, and skip work if you change your mind inside the same transaction. It is genuinely faster.

It is also genuinely surprising the first time it bites you — which is what brings us to question 2.


2. So what’s the difference between save and saveAndFlush?

Same final outcome on the happy path. Completely different in when the database gets to weigh in.

savesaveAndFlush
Returns immediatelyyesyes
INSERT actually sent to the DBlater (at commit or auto-flush)now
DB constraints checkedat commitat this line
Constraint violation surfacesat the commit boundaryas a normal exception, here, in this method

That last row is the one that matters for this whole pattern.

If you call save and you’ve violated a unique constraint, you will get an exception — eventually. It will be thrown between your method returning and Spring’s AOP committing the transaction, in a no-man’s-land where you can’t easily wrap it in try/catch. Your method has already exited. The exception travels up through AOP machinery you didn’t write.

If you call saveAndFlush, the INSERT is sent immediately, the database checks constraints immediately, and any DataIntegrityViolationException lands on the line you wrote. You can catch it with a normal try/catch. The exception lives where you can reach it.

That’s the whole reason this codebase uses saveAndFlush for the duplicate-attachment guard. The unique index needs to do its job synchronously, here, inside the method, not at some future commit moment. Otherwise the caller has nothing to catch.


3. Then what is “flush”, as a thing?

Flush is the verb for the Hibernate concept above: “stop buffering my pending writes and send the SQL to the database now.”

Walk through the lifecycle:

var attachment = new EmailAttachment(...);

repository.save(attachment);
// ↑ in memory: Hibernate notes "I'll INSERT this... eventually."
// ↑ DB: knows nothing yet. No SQL has been sent.

repository.flush();
// ↑ SQL sent NOW: INSERT INTO email_attachment (...) VALUES (...)
// ↑ DB: row exists IN THIS TRANSACTION. Constraints are checked.
// ↑ Other transactions: still don't see it (we haven't committed).

// ... method returns, Spring's AOP commits ...
// ↑ Now everyone sees it.

Three distinct moments people confuse all the time:

StateWhere the change livesVisible to other transactions?
After save (no flush yet)only in Hibernate’s memoryno
After flushin the database, inside your transactionno
After commitin the database, permanentlyyes

The “flush” step is the one most developers don’t have a clean mental model for, because they almost never call it explicitly. They write save, the transaction commits, life goes on. But the moment you need the DB to check something — a unique constraint, a foreign-key relationship, a generated column — before the transaction ends, the timing of the flush stops being something you can leave to luck.


4. So flush sends the SQL to the DB — then what does “commit” do that’s different?

The single most important sentence to internalise here:

Flush makes the data exist in the database. Commit makes it visible to everyone else.

Transactions are isolated by design. Your changes — once flushed — exist in the database, but they sit inside an open transaction that no other session can see into. You can still roll them back. They are real but private.

StateYour transaction sees it?Other transactions see it?Can still be rolled back?
After save onlyyes (via Hibernate cache)noyes
After flushyes (now in DB tables, in your tx)noyes
After commityesyesno

A useful analogy is editing a Google Doc in Suggesting mode versus accepting all suggestions. Flushing is the equivalent of typing the suggestions: they exist in the document, they’re real, but readers see them as drafts. Commit is Accept all. They become part of the document for everyone.

This separation is what makes the unique-constraint trick work: we want the constraint check (which is a flush thing — the DB has to evaluate the index against a real INSERT) to fire before the commit, while we’re still inside our method and able to catch the resulting exception cleanly.


5. Aside — but the UUID is already populated after save. How, if no SQL has been sent?

Good catch, and the answer is one of the underrated reasons UUIDs are so loved in modern distributed systems:

var attachment = EmailAttachment.builder()....build();
// attachment.getId() == null

repository.save(attachment);
// attachment.getId() == "330a1ee2-69dd-4d41-..."  ← populated, in memory
// DB: still knows nothing. No INSERT yet.

// ...later, at flush/commit
// INSERT INTO email_attachment (id, ...) VALUES ('330a1ee2-...', ...)

For @GeneratedValue(strategy = GenerationType.UUID), the UUID is generated by Hibernate, in your JVM. No database round-trip is needed to know the ID — it’s available the instant save returns. The actual row arrives in the DB only at flush.

Contrast this with IDENTITY (auto-increment columns like Postgres BIGSERIAL): the ID is generated by the database, on insert, so Hibernate has no choice but to flush immediately on save to get the value back. With IDENTITY, the “lazy persistence” behaviour we just talked about effectively doesn’t apply.

StrategyID known when?Does save hit the DB immediately?
UUIDas soon as save returnsno — defers to flush
SEQUENCEas soon as save returns (separate nextval query)no — defers to flush
IDENTITY (auto-increment)only after the INSERTyes — forced flush

This is why event-driven backends prefer UUIDs as primary keys: you can use the ID, set it on related entities, even publish a “thing X created” event — all before the row physically commits. The ID is yours the instant the object is constructed in memory.


6. OK, back to the method — what does @Transactional(propagation = REQUIRES_NEW) actually do?

A @Transactional method runs inside a database transaction. The default propagation (REQUIRED) means “if there’s already a transaction open, join it; if not, start one.” That sounds sensible — and for 90% of methods it is.

REQUIRES_NEW is different. It says “whatever transaction is open, suspend it. Start a brand-new one. When this method ends, commit (or roll back) only the new one, then resume the old one.”

So if a step in a Spring Batch job is already running in transaction T1, and it calls our addAttachmentInNewTransaction(...), the runtime:

  1. Suspends T1 — pauses it; doesn’t commit, doesn’t roll back.
  2. Starts a brand-new transaction T2 for our method.
  3. Runs the method body inside T2.
  4. On exit, commits (or rolls back) T2 only.
  5. Resumes T1, untouched.

T1 has no idea any of this happened. From T1’s point of view, a method got called and returned. Whatever T2 did to the DB is now permanent (if T2 committed) or never happened (if T2 rolled back), but in either case T1 is in exactly the state it was in before the call.

This is the foundation that makes the next question’s answer possible.


7. But why can’t I just do saveAndFlush in the outer transaction and catch the violation?

This is the most common wrong answer to “how do I make a unique constraint work as an idempotency guard”, and getting it wrong silently breaks your batch jobs months later. Worth slowing down for.

Here’s what happens if you skip REQUIRES_NEW:

// outer step transaction is active (T1)
try {
    attachmentRepository.saveAndFlush(attachment);
    // DB rejects → DataIntegrityViolationException thrown
} catch (DataIntegrityViolationException e) {
    log.info("duplicate, skipping");
    // we caught it, all good?
}

// ...step body continues, maybe touches the DB again...
// ...step ends, Spring tries to commit T1...
// → "Transaction is marked rollback-only" 💥
// step FAILS, even though we caught the exception

Once a constraint violation hits a transaction, Spring and the JTA spec mark the entire transaction as rollback-only. That status is sticky. Catching the exception does not unset it. Any subsequent DB call in the same transaction fails. The commit at the end of the step fails. Spring Batch sees the step as failed — even though, from your code’s point of view, you handled the duplicate gracefully and logged a friendly message.

The result: you “handle” the duplicate, the step still fails, the batch job logs a spurious failure, and a notification fires telling someone that the batch broke. The exact thing you were trying to avoid.

REQUIRES_NEW is what makes the rollback local. Only the inner transaction (T2) is marked rollback-only and dies. The outer transaction (T1) was suspended the whole time the violation was happening; nothing about T1’s state changed. When T1 resumes, it has no idea anything went wrong. Catching the exception in T1 is safe because the violation never touched T1.

The full timeline of one duplicate-attempt:

Caller (in step tx T1)
│
├─ try {
│     inboxService.addAttachmentInNewTransaction(...)   ← method call
│     │
│     │   [AOP intercepts because @Transactional(REQUIRES_NEW)]
│     │   - Suspend T1
│     │   - Start new tx T2  ←─────────────────── "the new one"
│     │
│     │   Inside the method body (running in T2):
│     │   ├─ attachment.setEmailInbox(inbox)
│     │   ├─ repository.saveAndFlush(attachment)
│     │   │   ├─ INSERT sent to DB (inside T2)
│     │   │   ├─ DB checks unique index
│     │   │   └─ ❌ violation → throws DataIntegrityViolationException
│     │   │       ↑ happens HERE, inside T2
│     │   │
│     │   [Exception propagates out of method body]
│     │   - AOP rolls back T2
│     │   - Resumes T1
│     │   - Re-throws the exception
│     │
│ } catch (DataIntegrityViolationException dup) {
│     log.info("duplicate, skipping");   ← caller catches it, in T1
│ }
│
└─ Step continues normally. T1 commits at end of step.

The exception is the same exception — born in T2, observed in T1. The reason this works is the symmetry: by the time the catch fires, T2 has rolled back and T1 has resumed. T1 was paused for the entirety of the violation. There is no rollback-only flag on T1, because T1 was suspended while the rollback happened.

That’s the whole point of REQUIRES_NEW for this pattern: it gives the violation a separate transaction to die in.

Embedded takeaway: any time you want a database constraint to act as an idempotency guard with clean conflict handling, the pattern is @Transactional(REQUIRES_NEW) + saveAndFlush, with the call wrapped in try/catch(DataIntegrityViolationException) by the caller. Both annotations are load-bearing. The propagation isolates the failure; the flush forces it to surface in time to be caught.


8. One last question — who actually commits the transaction? Spring or the database?

Both, working as a chain. Different layers do different jobs, and most “Spring transaction” confusion is from collapsing them into one box.

your code                     @Transactional(...)
                                     │
Spring AOP                    wraps the method;
                              at method exit, decides
                              commit (success) or rollback (exception)
                                     │
Spring TransactionManager     tells Hibernate "end this transaction"
                                     │
Hibernate (JPA provider)      auto-flushes any leftover pending writes,
                              then calls connection.commit() on JDBC
                                     │
JDBC driver                   sends the actual "COMMIT" command
                                     │
PostgreSQL                    writes to WAL, makes the changes
                              durable and visible to other sessions

So:

  • Who decides when to commit? Spring (based on your @Transactional boundary — commit on normal method exit, rollback if an exception propagates out).
  • Who sends the actual COMMIT SQL? The JDBC driver, on instructions from Hibernate.
  • Who executes the commit? Postgres (the DB engine — it’s the only one that can actually make data durable on disk).

That’s why @Transactional is such a small annotation that does so much. It’s just Spring offering to handle the commit/rollback orchestration for you by intercepting your method through AOP. Without it, every database-touching method would need its own scaffolding:

TransactionStatus tx = txManager.getTransaction(new DefaultTransactionDefinition());
try {
    // your method body
    txManager.commit(tx);
} catch (RuntimeException e) {
    txManager.rollback(tx);
    throw e;
}

Multiply that by a thousand methods, with nested transactions, propagation rules, isolation levels, and rollback-on conditions, and you get why @Transactional exists at all.

Spring’s value here is orchestration plus cross-cutting AOP magic. Postgres’s value is actually committing. Hibernate sits between them, batching and flushing your writes and translating Java method calls into SQL. They each do exactly one thing, and the annotation hides the cooperation.


Pulling it all back together

Eight lines, two annotations, one method call:

@Transactional(propagation = Propagation.REQUIRES_NEW)
public void addAttachmentInNewTransaction(EmailInbox inbox,
                                          EmailAttachment attachment) {
    attachment.setEmailInbox(inbox);
    attachmentRepository.saveAndFlush(attachment);
}

What’s actually happening:

  • REQUIRES_NEW — suspend the caller’s transaction; run this method in a brand-new one. If anything goes wrong in here, the rollback is local to this transaction, and the caller’s transaction is untouched.
  • saveAndFlush — don’t wait for the natural flush at commit time. Send the INSERT now, force the database to evaluate the unique constraint now, and let any DataIntegrityViolationException surface on this line, where the caller can catch it.
  • The caller’s try/catch(DataIntegrityViolationException) — treat the duplicate as a routine outcome, not an error. Log it, move on. The outer transaction is clean because the violation died in a different transaction.

Three small things, each doing exactly one job, building one of the cleanest idempotency patterns the Spring + Hibernate + Postgres stack gives you. Worth committing to memory — every backend that uses a unique constraint as an idempotency guard ends up needing exactly this combination.

The thing I wish someone had told me earlier is that the confusion isn’t really about the annotations. It’s about not having a clear picture of the four distinct moments a write goes through — save (in-memory), flush (SQL sent, constraints checked, still rollback-able), commit (durable and visible), and the AOP boundary around all of it that decides commit vs rollback in the first place. Once those four moments are separate in your head, everything REQUIRES_NEW and saveAndFlush do becomes obvious — they’re each just turning a dial on when one of those moments happens.


Imad Alilat is a freelance backend engineer working on Spring Boot, Spring Batch, and Postgres-heavy systems. More like this at devadvisor.io.

Related Posts

One Email. Two Invoices. The 10-Month Bug Hiding Behind a Slack Alert.

A Slack alert said “duplicate notification.”

A 2-line fix would have shipped it the same day.

Both were wrong.

Read more

Javadoc Best Practices for Spring Boot - 5-Minute Professional Guide 2025

You’ve written clean code. Your tests pass. Your PR is ready. Then comes the review comment: “Please add Javadoc.”

Sound familiar?

Read more

Spring Boot Testing Strategy – Context Management & Perf Secrets (Part 2)

Every second saved in test execution multiplies across your entire team and CI/CD pipeline. A 10x improvement in test performance can save hours of developer time daily—transforming a 10-minute test suite into a 1-minute feedback loop.

Read more