Back to Flush, Persist & Cache
Q4Question 4 of 5

Why does save() not immediately INSERT?

Key Concepts
  • save() delegates to persist() for new entities, which transitions them from TRANSIENT to MANAGED and queues an INSERT.
  • Hibernate uses write-behind: the INSERT is deferred to the next flush or commit, not executed immediately.
  • Deferral enables batching (multiple inserts in one JDBC batch), statement ordering (parents before children), and avoiding wasted work.
  • The exception is IDENTITY generation: the database generates the id via auto-increment, forcing an immediate INSERT to read the key.
  • IDENTITY generation defeats insert batching; SEQUENCE generation lets Hibernate pre-fetch ids and keep deferring inserts.
  • Because SQL is deferred, constraint violations may appear at flush/commit instead of at the save() line.
  • Once an entity is managed, further field changes do not need another save() because dirty checking handles them.

Definition

`save()` makes the entity managed and queues the INSERT; the SQL is deferred to flush or commit so Hibernate can batch and order statements.
  • save() delegates to persist() -- for a new entity, Spring Data's save() calls EntityManager.persist(), which transitions the entity from TRANSIENT to MANAGED.
  • INSERT queued -- Hibernate schedules an EntityInsertAction in 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.IDENTITY forces 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.

JAVA
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
    }
}
TEXT
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.

JAVA
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
    }
}
DIAGRAM
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.

JAVA
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
}
DIAGRAM
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.

JAVA
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
}
DIAGRAM
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 |

JAVA
// 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
TEXT
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.

JAVA
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;
    }
}
TEXT
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.

JAVA
// 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!
}
TEXT
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.

JAVA
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
}
TEXT
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

DIAGRAM
  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)
DIAGRAM
  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.

---

Share
Use ← → arrow keys to navigate