Definition
- save() delegates to persist() -- for a new entity, Spring Data's
save()callsEntityManager.persist(), which transitions the entity from TRANSIENT to MANAGED. - INSERT queued -- Hibernate schedules an
EntityInsertActionin its internal action queue but does not execute it immediately. - Write-behind -- the SQL is deferred until the next flush, which happens before commit, before an affected query, or on explicit
flush()call. - Batching -- deferral lets Hibernate group multiple inserts into a single JDBC batch instead of individual round trips.
- Statement ordering -- Hibernate reorders inserts so parent rows are written before child rows that reference them by foreign key.
- IDENTITY exception --
GenerationType.IDENTITYforces an immediate INSERT because the database generates the key via auto-increment.
Calling save() is like Priya filling out a new-account opening form and handing it to Rajesh at National Bank of India. Rajesh accepts the form and starts tracking it (the account is managed), but he does not immediately walk to the vault -- he batches all the morning's forms and processes them together before closing the counter. The form is accepted and pending, just not yet written into the master ledger.
Understanding the Concepts
What happens internally when you call save()?
The entity lifecycle is the key. A brand-new object created with new is in the TRANSIENT state -- Hibernate knows nothing about it. Calling persist() (which Spring Data's save() uses for new entities) transitions it to MANAGED. Hibernate records it in the persistence context, takes a snapshot for dirty checking, and enqueues an INSERT. The row is not in the database yet.
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 SaveInternalsDemo {
private final AccountRepository accountRepo;
public SaveInternalsDemo(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
@Transactional
public void openAccount() {
Account account = new Account("SAV9999", "Priya", BigDecimal.ZERO);
// State: TRANSIENT -- Hibernate does not know about it
accountRepo.save(account);
// State: MANAGED -- INSERT queued, snapshot taken
// SQL log: EMPTY -- no INSERT yet!
// INSERT runs at the next flush or at commit
}
}
Expected SQL log:
-- After accountRepo.save(account): no SQL
-- At transaction commit: INSERT INTO accounts (id, holder_name, balance) VALUES ('SAV9999', 'Priya', 0)
Why does Hibernate defer the INSERT?
There are three concrete payoffs to deferring SQL execution.
First, batching. If you save fifty entities, Hibernate can send them as one JDBC batch instead of fifty round trips, provided batching is configured.
Second, statement ordering. Hibernate can reorder inserts so parent rows are written before child rows that reference them by foreign key.
Third, avoiding wasted work. If you save then immediately delete an entity in the same transaction before any flush, Hibernate may skip the INSERT entirely.
package com.bank.service;
import com.bank.model.Account;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class DeferralBenefitsDemo {
private final EntityManager em;
public DeferralBenefitsDemo(EntityManager em) {
this.em = em;
}
@Transactional
public void batchingSavings() {
// 50 persists queued, then one JDBC batch of 50 inserts at flush
for (int i = 0; i < 50; i++) {
Account a = new Account("SAV" + i, "Customer " + i, BigDecimal.ZERO);
em.persist(a); // INSERT queued, not executed
}
// At commit: 50 inserts sent as one JDBC batch
}
@Transactional
public void wastedWorkAvoided() {
Account temp = new Account("TEMP-001", "Temporary", BigDecimal.ZERO);
em.persist(temp); // INSERT queued
em.remove(temp); // DELETE queued
// At flush: Hibernate may skip both -- no wasted I/O
}
}
Batching benefit: - 50 persists → 1 JDBC batch of 50 inserts → 1 network round trip - Without batching: 50 persists → 50 individual inserts → 50 round trips Wasted work benefit: - persist then remove before flush → Hibernate skips both
Why does IDENTITY generation force an immediate INSERT?
With @GeneratedValue(strategy = GenerationType.IDENTITY), the database generates the id via an auto-increment column. Hibernate must run the INSERT immediately on persist() to learn the generated id and populate the entity. This defeats batching because inserts cannot be deferred.
package com.bank.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
@Entity
public class LegacyAccount {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY) // auto-increment
private Long id;
private String holderName;
// With IDENTITY: persist() forces INSERT immediately to read the id
// Batching is DISABLED -- each persist is a separate round trip
}
With IDENTITY: - persist(account) → INSERT runs NOW → id=1 read back - persist(account) → INSERT runs NOW → id=2 read back - 50 persists = 50 immediate inserts = 50 round trips (no batching!)
Why is SEQUENCE generation preferred for bulk inserts?
With GenerationType.SEQUENCE, Hibernate fetches id values from the sequence before inserting. It knows the ids up front and can defer and batch the inserts. A pooled allocation size means Hibernate grabs many ids per sequence call.
package com.bank.model;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.GenerationType;
import jakarta.persistence.Id;
import jakarta.persistence.SequenceGenerator;
@Entity
public class ModernAccount {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE, generator = "acct_gen")
@SequenceGenerator(name = "acct_gen", sequenceName = "account_seq",
allocationSize = 50)
private Long id;
private String holderName;
// With SEQUENCE: Hibernate pre-fetches 50 ids, then defers inserts
// Batching WORKS -- 50 inserts per JDBC batch
}
With SEQUENCE (allocationSize=50):
- Hibernate calls SELECT nextval('account_seq') → gets range 1-50
- persist(account1) → id=1, INSERT queued (not executed)
- persist(account2) → id=2, INSERT queued (not executed)
- ...
- persist(account50) → id=50, INSERT queued
- flush() → 50 inserts sent as ONE JDBC batch
- 1 sequence call + 1 batch = 2 round trips for 50 entities
How do IDENTITY and SEQUENCE compare for batching?
| Aspect | IDENTITY | SEQUENCE |
|--------|----------|----------|
| ID generation | DB auto-increment | DB sequence |
| When ID is known | After INSERT | Before INSERT |
| INSERT deferral | No (immediate) | Yes (deferred) |
| JDBC batching | Disabled | Enabled |
| Round trips for 50 rows | 50 | 2 (1 seq + 1 batch) |
| Best for | Legacy schemas | Bulk inserts |
// IDENTITY: 50 inserts = 50 round trips
for (int i = 0; i < 50; i++) {
em.persist(new LegacyAccount("Customer " + i)); // INSERT runs immediately
}
// SEQUENCE: 50 inserts = 2 round trips
for (int i = 0; i < 50; i++) {
em.persist(new ModernAccount("Customer " + i)); // INSERT deferred
}
em.flush(); // 50 inserts in one JDBC batch
Performance comparison (50 inserts):
IDENTITY: 50 round trips × 1ms each = ~50ms
SEQUENCE + batch: 2 round trips = ~2ms (25x faster)
What are the default auto-flush behaviors?
With FlushMode.AUTO, Hibernate flushes the queued INSERT before any query that might be affected. This means even without explicit flush(), a query on the same table triggers the deferred INSERT.
package com.bank.service;
import com.bank.model.Account;
import jakarta.persistence.EntityManager;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class AutoFlushInsertDemo {
private final EntityManager em;
public AutoFlushInsertDemo(EntityManager em) {
this.em = em;
}
@Transactional
public long demonstrateAutoFlush() {
Account account = new Account("SAV9999", "Priya", BigDecimal.ZERO);
em.persist(account); // INSERT queued, not executed
// This JPQL query touches the accounts table
// AUTO flush fires the queued INSERT before the query
long count = em.createQuery("SELECT COUNT(a) FROM Account a", Long.class)
.getSingleResult();
// SAV9999 is now counted because the INSERT ran before the query
return count;
}
}
Expected behavior:
1. em.persist(account) -- INSERT queued, no SQL yet
2. JPQL COUNT query -- touches accounts table
3. AUTO flush fires -- INSERT INTO accounts ... executes
4. COUNT query runs -- includes the new row
What are common mistakes around save and deferred inserts?
The most common mistake is expecting SQL to run immediately after save() and being confused when the SQL log is empty. The second is debugging a constraint violation that appears at commit instead of at the save() line.
// WRONG assumption: SQL runs at save
@Transactional
public void confusedDeveloper() {
Account a = new Account("SAV2001", "Priya", BigDecimal.ZERO);
accountRepo.save(a);
// Developer checks SQL log -- EMPTY!
// "save is broken!" -- no, SQL is deferred to flush/commit
}
// WRONG: constraint violation appears at commit, not at save
@Transactional
public void constraintSurprise() {
Account duplicate = new Account("SAV2001", "Duplicate", BigDecimal.ZERO);
accountRepo.save(duplicate); // no error here!
// ... 50 more lines of code ...
// EXCEPTION at commit: DataIntegrityViolationException (duplicate key)
// Stack trace points to the proxy, not to your save line!
}
Fixes:
1. Use saveAndFlush() to force immediate SQL and surface errors early
2. Understand that save() queues SQL -- check the log at commit time
3. Use Hibernate SQL logging: spring.jpa.show-sql=true
What should a senior engineer know about deferred inserts?
A senior engineer chooses SEQUENCE generation for bulk workloads, configures jdbc.batch_size and order_inserts, and understands that save() on a managed entity is redundant because dirty checking handles updates. They know that the deferral is deliberate and beneficial, not a bug.
package com.bank.config;
import org.springframework.context.annotation.Configuration;
// application.yml for efficient batch inserts
// spring:
// jpa:
// properties:
// hibernate:
// jdbc.batch_size: 50
// order_inserts: true
// order_updates: true
// show-sql: true
// open-in-view: false
@Configuration
public class BatchConfig {
// Configuration in application.yml
// SEQUENCE + batch_size + order_inserts = efficient bulk inserts
}
Senior checklist:
1. Deferred inserts are a feature, not a bug -- enables batching
2. IDENTITY defeats batching; SEQUENCE enables it
3. Configure batch_size + order_inserts for efficient bulk loads
4. save() on a managed entity is redundant -- dirty checking handles it
5. Use saveAndFlush() when you need immediate constraint feedback
6. Check Hibernate statistics to verify batches are forming
Visual Overview
save() / persist() Internal Flow
new Account() persist() flush()/commit()
│ │ │
▼ ▼ ▼
┌──────────┐ ┌────────────────┐ ┌──────────────┐
│ TRANSIENT │──────►│ MANAGED │──────►│ DATABASE │
│ (unknown │ │ (tracked, │ │ (INSERT │
│ to JPA) │ │ INSERT queued, │ │ executed) │
└──────────┘ │ snapshot taken)│ └──────────────┘
└────────────────┘
IDENTITY exception:
persist() ──► INSERT runs IMMEDIATELY ──► id read back
(cannot defer because id is unknown until INSERT)
SEQUENCE:
persist() ──► id pre-fetched from sequence ──► INSERT deferred
(id known up front, inserts can batch)
Batching comparison: IDENTITY (50 inserts): ┌─┐┌─┐┌─┐┌─┐┌─┐...┌─┐ = 50 round trips └─┘└─┘└─┘└─┘└─┘ └─┘ SEQUENCE + batch (50 inserts): ┌───────────────────────┐ = 1 round trip │ 50 inserts in batch │ └───────────────────────┘
Key Takeaway
When you call save(), Hibernate attaches the entity to the persistence context, moves it from TRANSIENT to MANAGED, takes a snapshot, and schedules an INSERT in its action queue. What it does not do is execute that INSERT immediately. Hibernate uses write-behind: the SQL is deferred until the next flush, which normally happens before a dependent query or at commit. This deferral is a deliberate optimization that enables JDBC batching, statement ordering, and avoiding wasted work.
The important exception is IDENTITY generation. With an auto-increment primary key, the database produces the id only after the row is inserted, so Hibernate must execute the INSERT immediately on persist() to read the generated id. This defeats batching entirely. SEQUENCE generation fixes this because Hibernate pre-fetches ids from the sequence, knows them up front, and can defer and batch the inserts.
At National Bank of India, Rajesh's bulk onboarding job created accounts in a loop, and a developer thought save() was broken because the SQL log stayed empty until the method returned. The inserts were queued and flushed in one batch at commit. The job was also slow because the entity used IDENTITY ids, forcing an immediate insert per save(). Switching to a SEQUENCE with a pooled allocation size and configuring JDBC batch_size let the inserts batch, and the job sped up dramatically.
Remember one sentence: save() queues the INSERT for batching and defers it to flush or commit, unless IDENTITY generation forces an immediate execute to read the auto-generated key.
---