Definition
- save() -- makes the entity managed and schedules the INSERT or UPDATE in Hibernate's action queue, but the SQL is buffered and flushed at the normal flush point (before a dependent query or at commit).
- saveAndFlush() -- does the same persist and then immediately calls
flush(), forcing Hibernate to execute the pending SQL right away within the same transaction. - Same transaction -- both run inside the open transaction and both can still be rolled back; neither commits.
- Write-behind --
save()lets Hibernate use write-behind to batch and reorder statements for better throughput. - Immediate feedback --
saveAndFlush()gives immediate feedback on constraint violations and database-generated values.
save() is like Priya handing a filled deposit slip to the teller at National Bank of India, who puts it in the tray to process with the rest of the batch before closing the counter. saveAndFlush() is like asking the teller to key in that one slip right now while Priya waits, so she can immediately see it reflected. But the day's books are still not closed (committed) until the counter shuts.
Understanding the Concepts
What does save() do internally?
In Spring Data JPA, save() delegates to EntityManager.persist() for a new entity or merge() for a detached one. For a new entity, persist() makes it managed and enqueues an EntityInsertAction. The row is not in the database yet -- Hibernate uses write-behind.
package com.bank.service;
import com.bank.model.Trade;
import com.bank.repository.TradeRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class TradeCaptureService {
private final TradeRepository tradeRepo;
public TradeCaptureService(TradeRepository tradeRepo) {
this.tradeRepo = tradeRepo;
}
@Transactional
public void captureTrade() {
Trade trade = new Trade("TRD-001", "RELIANCE", 100, new BigDecimal("2450.50"));
tradeRepo.save(trade); // entity managed, INSERT queued -- maybe no SQL yet
// ... more work ...
// INSERT actually runs at flush/commit
}
}
Expected SQL log:
-- After tradeRepo.save(trade): NO SQL in the log yet
-- At transaction commit: INSERT INTO trades (id, symbol, qty, price) VALUES (?, ?, ?, ?)
What does saveAndFlush() do differently?
saveAndFlush() calls save and then flush() immediately, so the INSERT or UPDATE runs right now.
package com.bank.service;
import com.bank.model.Trade;
import com.bank.repository.TradeRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class ImmediateTradeCaptureService {
private final TradeRepository tradeRepo;
public ImmediateTradeCaptureService(TradeRepository tradeRepo) {
this.tradeRepo = tradeRepo;
}
@Transactional
public void captureTradeImmediate() {
Trade trade = new Trade("TRD-002", "TCS", 50, new BigDecimal("3200.00"));
tradeRepo.saveAndFlush(trade); // INSERT executes immediately
// SQL log shows INSERT right now, within the same TX
}
}
Expected SQL log:
-- After tradeRepo.saveAndFlush(trade): INSERT INTO trades ... (runs NOW)
-- Still inside the transaction -- can be rolled back
How do save() and saveAndFlush() compare?
| Aspect | save() | saveAndFlush() |
|--------|--------|----------------|
| SQL timing | Deferred to next flush/commit | Immediate |
| Transaction | Still inside TX | Still inside TX |
| Can roll back | Yes | Yes |
| Batching | Yes (Hibernate can batch) | Defeats batching |
| Constraint error timing | At commit (confusing stack trace) | At save line (clear stack trace) |
| Performance in bulk | Better (batch + reorder) | Worse (one flush per call) |
// Comparison in one method
@Transactional
public void compareApproaches() {
Trade t1 = new Trade("TRD-003", "INFY", 200, new BigDecimal("1500.00"));
tradeRepo.save(t1); // INSERT queued, no SQL yet
Trade t2 = new Trade("TRD-004", "HDFC", 100, new BigDecimal("1650.00"));
tradeRepo.saveAndFlush(t2); // INSERT for t2 runs NOW
// Also flushes t1's queued INSERT (flush drains all pending)
}
Expected SQL log:
1. tradeRepo.save(t1) -- no SQL
2. tradeRepo.saveAndFlush(t2) -- INSERT t1, INSERT t2 (flush drains all pending)
When should you use saveAndFlush()?
There are three specific situations where saveAndFlush() is the right choice.
First, when you need a constraint violation to surface immediately at a known line rather than later at commit where the stack trace is confusing.
package com.bank.service;
import com.bank.model.Account;
import com.bank.repository.AccountRepository;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class ConstraintCheckService {
private final AccountRepository accountRepo;
public ConstraintCheckService(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
@Transactional
public void createAccountWithCheck(String id, String holder) {
Account account = new Account(id, holder, BigDecimal.ZERO);
try {
accountRepo.saveAndFlush(account); // constraint violation surfaces HERE
} catch (DataIntegrityViolationException e) {
throw new DuplicateAccountException("Account " + id + " already exists");
}
}
}
With saveAndFlush: DataIntegrityViolationException at line 20
With save: same exception at commit -- stack trace points to proxy, not your code
Second, when a later step in the same transaction reads through a path that bypasses the persistence context, such as a native SQL query.
@Transactional
public void saveAndVerify(Trade trade) {
tradeRepo.saveAndFlush(trade); // INSERT runs now
// Native query bypasses the persistence context
Long count = em.createNativeQuery("SELECT COUNT(*) FROM trades WHERE symbol = :s")
.setParameter("s", trade.getSymbol())
.getSingleResult();
// Without flush, the native query would not see the row!
}
With saveAndFlush: native query sees the row (count includes it)
With save: native query may not see the row (not yet flushed)
When should you NOT use saveAndFlush()?
Avoid saveAndFlush() in bulk operations because it defeats Hibernate's ability to batch inserts and reorder SQL.
// WRONG: saveAndFlush in a loop -- one flush per iteration, no batching
@Transactional
public void bulkInsertWrong(List<Trade> trades) {
for (Trade t : trades) {
tradeRepo.saveAndFlush(t); // flushes after EVERY insert -- slow!
}
}
// CORRECT: save in a loop, let Hibernate batch at commit
@Transactional
public void bulkInsertCorrect(List<Trade> trades) {
int i = 0;
for (Trade t : trades) {
tradeRepo.save(t); // INSERT queued
if (++i % 50 == 0) {
em.flush(); // batch of 50 inserts
em.clear(); // free memory
}
}
}
WRONG: 10,000 trades = 10,000 flushes = 10,000 round trips
CORRECT: 10,000 trades = 200 flushes of 50 = much faster
Do you need save() on an already-managed entity?
No. For an already-managed entity, you do not need save() at all. Dirty checking detects the field change and flushes the UPDATE automatically. Calling save() on a managed entity triggers merge(), which is redundant.
// REDUNDANT: save on a managed entity
@Transactional
public void updateRedundant(String accountId) {
Account a = accountRepo.findById(accountId).orElseThrow(); // managed
a.setStatus("FROZEN");
accountRepo.save(a); // REDUNDANT -- merge on a managed entity does nothing useful
}
// CORRECT: just change the field
@Transactional
public void updateCorrect(String accountId) {
Account a = accountRepo.findById(accountId).orElseThrow(); // managed
a.setStatus("FROZEN");
// dirty checking generates UPDATE at commit -- no save needed
}
Both produce the same SQL:
UPDATE accounts SET status='FROZEN' WHERE id='SAV2001'
But the redundant save() adds an unnecessary merge() call
How does saveAndFlush interact with Hibernate's write-behind?
Calling saveAndFlush() in a loop forces Hibernate to drain the action queue after every persist, which prevents batching and statement reordering. In a single save, the cost is negligible. In a bulk operation, the accumulated cost is significant.
// Measuring the cost of saveAndFlush in a loop
@Transactional
public void benchmark() {
long start = System.currentTimeMillis();
for (int i = 0; i < 1000; i++) {
tradeRepo.saveAndFlush(new Trade("TRD-" + i, "SYM", 100, java.math.BigDecimal.ONE));
}
long elapsed = System.currentTimeMillis() - start;
// elapsed: ~5000ms (1000 individual flushes)
start = System.currentTimeMillis();
for (int i = 0; i < 1000; i++) {
tradeRepo.save(new Trade("TRD-" + i, "SYM", 100, java.math.BigDecimal.ONE));
}
// flush at commit: ~200ms (batched if configured)
}
saveAndFlush in a loop: 1000 flushes = ~5000ms
save + batch commit: 1 flush = ~200ms (25x faster)
What should a senior engineer know about save vs saveAndFlush?
A senior engineer defaults to save() for throughput and reaches for saveAndFlush() only when they need immediate constraint feedback, native query visibility, or database-generated values. They know that saveAndFlush() still does not commit, so the row is invisible to other transactions. They also know that forcing a flush too often defeats Hibernate's batching and statement reordering.
package com.bank.service;
import com.bank.model.Account;
import com.bank.repository.AccountRepository;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class SeniorSavePattern {
private final AccountRepository accountRepo;
public SeniorSavePattern(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
@Transactional
public Account openAccount(String id, String holder) {
Account account = new Account(id, holder, BigDecimal.ZERO);
accountRepo.saveAndFlush(account);
// DB default columns (created_at, version) now populated on the entity
return account;
}
}
Senior checklist:
1. Default to save() for throughput and batching
2. Use saveAndFlush() for immediate constraint checks or native query visibility
3. saveAndFlush() still does not commit -- row invisible to other TXs
4. For managed entities, save() is redundant -- dirty checking handles updates
5. Never use saveAndFlush() in a bulk loop -- defeats batching
Visual Overview
save() timeline:
──┬──────────────────────┬──────────────────┬──► Time
│ │ │
save() auto-flush commit
(entity managed, (or manual flush) (TX ends)
INSERT queued) INSERT executes changes permanent
saveAndFlush() timeline:
──┬──────────────────────────────────────────┬──► Time
│ │
saveAndFlush() commit
(entity managed + INSERT executes NOW) (TX ends)
Bulk insert comparison: save() in a loop with batch flush: ┌─────┐ ┌─────┐ ┌─────┐ ┌─────┐ │ 50 │ │ 50 │ │ 50 │ │ 50 │ = 4 round trips └─────┘ └─────┘ └─────┘ └─────┘ saveAndFlush() in a loop: ┌─┐┌─┐┌─┐┌─┐┌─┐┌─┐┌─┐┌─┐┌─┐┌─┐ = 200 round trips └─┘└─┘└─┘└─┘└─┘└─┘└─┘└─┘└─┘└─┘
Key Takeaway
save() in Spring Data JPA makes the entity managed and schedules the INSERT, but the actual SQL is buffered and flushed at the next flush point, usually just before commit. saveAndFlush() does the same persist and then immediately calls flush(), forcing the SQL to execute right away. The only difference is timing -- both run inside the transaction and both can still be rolled back.
Default to save() because letting Hibernate buffer enables batching and statement reordering, which is far better for throughput in bulk operations. Reach for saveAndFlush() in three specific situations: when you need a constraint violation to surface at a known line with a clear stack trace, when a native query later in the same transaction must see the row, or when you need database-generated values (defaults, triggers) to be immediately available on the entity.
At National Bank of India, the trade capture service used plain save() in the normal batch path so inserts batched efficiently at commit. But the reconciliation method saved a trade and then ran a native SQL cross-check that could not see the row until flushed, so Rajesh used saveAndFlush() there. Duplicate trade ids whose constraint error appeared at commit far from the cause were also fixed by switching to saveAndFlush() to surface the violation at the save line.
Remember one sentence: save() defers SQL for batching and throughput, saveAndFlush() forces it immediately for constraint feedback or native query visibility, and neither one commits.
---