Definition
- Transient – a new object created with
new. Hibernate does not know it exists and there is no matching database row. Callpersist()to make it Managed. - Managed (Persistent) – the entity is tracked by the persistence context. Any field change is automatically detected by dirty checking and flushed to the database at commit time.
- Detached – the entity was Managed, but the session closed or the entity was evicted. Changes are not tracked. Call
merge()to copy its state into a new Managed instance. - Removed – the entity is scheduled for a DELETE statement on the next flush.
- persist() vs merge() –
persist()takes a Transient entity and makes it Managed (queues INSERT).merge()takes a Detached entity and returns a Managed copy (queues UPDATE).
At National Bank of India, a new account application form is Transient – the bank does not know about it yet. Once Rajesh submits it and the bank registers it, the form is Managed – any change Rajesh makes is automatically tracked. When Rajesh takes the form home, it becomes Detached – the bank is no longer watching it. If he brings it back, the bank re-registers it (merge). If the account is closed, the form is Removed – scheduled for shredding.
Understanding the Concepts
What are entity lifecycle states?
Every entity Hibernate knows about is in exactly one of four lifecycle states at any moment. Understanding these states is what separates someone who uses JPA from someone who truly understands it.
The first state is Transient (also called New). This is an object you just created with new. Hibernate does not know it exists, it is not in the persistence context, and there is no matching row in the database.
The second state is Managed (also called Persistent). Once an entity is in the persistence context, Hibernate tracks it. Any change you make to its fields is automatically detected through dirty checking, and those changes are flushed to the database when the transaction commits.
The third state is Detached. This is an entity that was Managed, but the session closed or the entity was evicted. Hibernate is no longer watching it, so changes are not tracked.
The fourth state is Removed. The entity is scheduled for a DELETE on the next flush.
package com.bank.service;
import com.bank.model.Account;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class AccountLifecycleDemo {
@PersistenceContext
private EntityManager em;
@Transactional
public void demonstrateStates() {
// 1. TRANSIENT: new object, not managed
Account account = new Account();
account.setAccountId("SAV2001");
account.setHolderName("Rajesh Kumar");
account.setBalance(new BigDecimal("500000.00"));
// account is TRANSIENT here
// 2. MANAGED: persist makes it tracked
em.persist(account);
// account is now MANAGED. INSERT queued for flush.
// 3. Dirty checking: change is auto-detected
account.setBalance(new BigDecimal("450000.00"));
// No explicit save needed! Hibernate detects the change.
// UPDATE queued for flush.
// 4. REMOVED: scheduled for DELETE
em.remove(account);
// account is now REMOVED. DELETE queued for flush.
}
}
What is the difference between persist() and merge()?
persist() takes a Transient entity and makes it Managed, queuing an INSERT. merge() takes a Detached entity and returns a Managed copy, queuing an UPDATE. The critical subtlety: merge returns a new Managed instance. The object you passed in stays Detached.
package com.bank.service;
import com.bank.model.Account;
import jakarta.persistence.EntityManager;
import jakarta.persistence.PersistenceContext;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
@Service
public class PersistVsMergeDemo {
@PersistenceContext
private EntityManager em;
@Transactional
public void persistExample() {
Account account = new Account("SAV2001", "Rajesh Kumar",
new BigDecimal("500000.00"));
em.persist(account);
// account is now MANAGED
// INSERT INTO accounts VALUES ('SAV2001', 'Rajesh Kumar', 500000.00)
}
@Transactional
public void mergeExample(Account detachedAccount) {
// WRONG: using the detached object after merge
// em.merge(detachedAccount);
// detachedAccount is STILL detached!
// CORRECT: use the returned managed copy
Account managed = em.merge(detachedAccount);
managed.setBalance(new BigDecimal("400000.00"));
// UPDATE queued for flush using the managed copy
}
}
How does Spring Data JPA's save() decide between persist and merge?
Spring Data JPA's save() wraps both operations. It calls persist() if the entity is new and merge() if the entity already has an identifier. An entity is considered new if its @Id is null, or if it implements Persistable and isNew() returns true.
package com.bank.model;
import jakarta.persistence.*;
import org.springframework.data.domain.Persistable;
import java.math.BigDecimal;
@Entity
@Table(name = "accounts")
public class Account implements Persistable<String> {
@Id
@Column(name = "account_id")
private String accountId;
private String holderName;
private BigDecimal balance;
@Transient
private boolean isNew = true;
@Override
public String getId() { return accountId; }
@Override
public boolean isNew() { return isNew; }
@PostPersist
@PostLoad
void markNotNew() { this.isNew = false; }
// After persist or load, entity is no longer new
// save() will call merge() for subsequent saves
}
What is optimistic locking with @Version?
Optimistic locking uses a @Version field. Hibernate reads the version when loading the entity and checks it on every UPDATE. If another transaction changed the row, the version no longer matches, zero rows are updated, and Hibernate throws OptimisticLockingFailureException.
package com.bank.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
@Entity
@Table(name = "accounts")
public class Account {
@Id
@Column(name = "account_id")
private String accountId;
private String holderName;
private BigDecimal balance;
@Version
private int version; // Hibernate auto-increments and checks this
}
Transaction 1: reads Account SAV2001, version=1
Transaction 2: reads Account SAV2001, version=1
Transaction 1: UPDATE accounts SET balance=400000, version=2
WHERE account_id='SAV2001' AND version=1
-- 1 row updated. Committed.
Transaction 2: UPDATE accounts SET balance=300000, version=2
WHERE account_id='SAV2001' AND version=1
-- 0 rows updated! Version is now 2, not 1.
-- Throws: OptimisticLockingFailureException
What is pessimistic locking with SELECT FOR UPDATE?
Pessimistic locking assumes conflicts are likely, so it locks the row up front. @Lock(LockModeType.PESSIMISTIC_WRITE) issues a SELECT ... FOR UPDATE, which takes a real database row lock. Other transactions that want the same row must wait.
package com.bank.repository;
import com.bank.model.Account;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.Optional;
public interface AccountRepository extends JpaRepository<Account, String> {
@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("SELECT a FROM Account a WHERE a.accountId = :id")
Optional<Account> findAndLock(@Param("id") String accountId);
// Generates: SELECT * FROM accounts WHERE account_id = ? FOR UPDATE
// Row is locked until this transaction commits or rolls back
}
How is optimistic different from pessimistic locking?
| Aspect | Optimistic (@Version) | Pessimistic (FOR UPDATE) |
|--------|----------------------|-------------------------|
| Lock held | No lock during transaction | Row locked from SELECT to COMMIT |
| Conflict detection | At commit time (version check) | At SELECT time (other TXs wait) |
| Performance | Better for low contention | Better for high contention |
| Failure mode | OptimisticLockingFailureException | Other transactions wait (or timeout) |
| Use case | Read-heavy, rare conflicts | Balance updates, seat booking |
package com.bank.service;
import com.bank.model.Account;
import com.bank.repository.AccountRepository;
import org.springframework.retry.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import java.math.BigDecimal;
@Service
public class TransferService {
private final AccountRepository accountRepo;
public TransferService(AccountRepository accountRepo) {
this.accountRepo = accountRepo;
}
// Optimistic: retry on conflict
@Retryable(value = ObjectOptimisticLockingFailureException.class, maxAttempts = 3)
@Transactional
public void transferOptimistic(String fromId, String toId, BigDecimal amount) {
Account from = accountRepo.findById(fromId).orElseThrow();
Account to = accountRepo.findById(toId).orElseThrow();
from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));
// @Version checks at commit. Retry if conflict.
}
// Pessimistic: lock rows immediately
@Transactional
public void transferPessimistic(String fromId, String toId, BigDecimal amount) {
Account from = accountRepo.findAndLock(fromId).orElseThrow();
Account to = accountRepo.findAndLock(toId).orElseThrow();
from.setBalance(from.getBalance().subtract(amount));
to.setBalance(to.getBalance().add(amount));
// Rows locked. No other transaction can modify these until commit.
}
}
What is keyset pagination vs offset pagination?
Offset pagination uses OFFSET N LIMIT M. The database scans and discards N rows before returning M rows. Deep pages get painfully slow. Keyset pagination remembers the last seen ID and asks for rows after it. The database does an index seek straight to the right spot.
package com.bank.repository;
import com.bank.model.Trade;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import java.util.List;
public interface TradeRepository extends JpaRepository<Trade, String> {
// Keyset pagination: fast for deep pages
@Query("SELECT t FROM Trade t WHERE t.tradeId > :lastId ORDER BY t.tradeId ASC")
List<Trade> findNextPage(@Param("lastId") String lastSeenId,
org.springframework.data.domain.Pageable pageable);
// Page 1: lastId = '', LIMIT 20
// Page 2: lastId = 'TRD-020', LIMIT 20
// Always fast: index seek, no row scanning
}
Offset pagination (page 10000, size 20):
SELECT * FROM trades ORDER BY trade_id OFFSET 200000 LIMIT 20
-- Database scans 200,000 rows, discards them, returns 20. SLOW.
Keyset pagination (after TRD-200000, size 20):
SELECT * FROM trades WHERE trade_id > 'TRD-200000'
ORDER BY trade_id LIMIT 20
-- Index seek to TRD-200000, read 20 rows. FAST.
What are lifecycle callbacks?
JPA lifecycle callbacks let you run code at specific points in an entity's lifecycle. Common callbacks: @PrePersist (before INSERT), @PreUpdate (before UPDATE), @PostLoad (after SELECT).
package com.bank.model;
import jakarta.persistence.*;
import java.math.BigDecimal;
import java.time.LocalDateTime;
@MappedSuperclass
public abstract class BaseEntity {
@Column(name = "created_at", updatable = false)
private LocalDateTime createdAt;
@Column(name = "updated_at")
private LocalDateTime updatedAt;
@PrePersist
protected void onCreate() {
this.createdAt = LocalDateTime.now();
this.updatedAt = LocalDateTime.now();
}
@PreUpdate
protected void onUpdate() {
this.updatedAt = LocalDateTime.now();
}
}
@Entity
@Table(name = "accounts")
public class Account extends BaseEntity {
@Id
private String accountId;
private String holderName;
private BigDecimal balance;
@Version
private int version;
}
// Every persist auto-sets createdAt and updatedAt
// Every update auto-refreshes updatedAt
Visual Overview
Entity Lifecycle States:
new Account()
│
▼
┌──────────────┐
│ TRANSIENT │ (not managed, no DB row)
└──────┬───────┘
│ persist()
▼
┌──────────────┐
│ MANAGED │ (tracked, dirty checking active)
└──┬───────┬───┘
│ │
│ detach │ remove()
│ /close │
▼ ▼
┌────────┐ ┌─────────┐
│DETACHED│ │ REMOVED │ (scheduled for DELETE)
└────┬───┘ └─────────┘
│ merge()
▼
┌──────────────┐
│ MANAGED │ (re-attached, UPDATE queued)
└──────────────┘
Optimistic vs Pessimistic Locking:
┌────────────────────┐ ┌─────────────────────┐
│ Optimistic │ │ Pessimistic │
│ @Version │ │ SELECT ... FOR UPDATE │
│ │ │ │
│ No lock held │ │ Row locked from │
│ Check at commit │ │ SELECT to COMMIT │
│ Retry on conflict │ │ Others wait │
└────────────────────┘ └─────────────────────┘
Key Takeaway
JPA entities move through four lifecycle states. Transient means the object exists only in Java memory. Managed means Hibernate is tracking every field change and will flush those changes as SQL at commit time. Detached means the entity was previously managed but the session closed. Removed means a DELETE is queued. The key operations are persist() (Transient to Managed, INSERT) and merge() (Detached to Managed, UPDATE).
At National Bank of India, Rajesh uses @Version for optimistic locking on the Account entity. Two concurrent transfers both read version 1. The first commits and increments to version 2. The second finds that version 1 no longer matches, gets OptimisticLockingFailureException, and retries with @Retryable. For high-contention operations like balance updates during salary day, Rajesh uses pessimistic locking with SELECT FOR UPDATE to lock the row immediately.
Keyset pagination is the senior-level approach for deep pages. Instead of OFFSET 200000 LIMIT 20 (which scans and discards 200,000 rows), keyset pagination uses WHERE trade_id > 'TRD-200000' LIMIT 20, which does an index seek and reads exactly 20 rows. Lifecycle callbacks like @PrePersist and @PreUpdate on a @MappedSuperclass auto-stamp createdAt and updatedAt on every entity without any manual code.
Remember one sentence: persist makes it managed, merge re-attaches it, @Version protects it, and dirty checking handles the rest.
---