Follow a JPA Entity from Java State to SQL
Spring & backendA deep dive into the Hibernate persistence lifecycle, entity states, and the mechanics of how Java objects become database rows.
On this page
The Architecture of Persistence
To understand how a Java object becomes a database row, we must distinguish between three distinct layers: JPA (the specification/interface), Hibernate (the implementation/engine), and the Database (the storage, e.g., PostgreSQL).
When you use a JpaRepository, you are interacting with a Spring Data abstraction that delegates to the JPA EntityManager. The EntityManager manages a Persistence Context—essentially a first-level cache that tracks every entity loaded or saved during a transaction. This context is the “brain” that decides whether a Java change requires a SQL UPDATE.
Entity States and the Lifecycle
An entity exists in one of four states relative to the Persistence Context:
- New (Transient): The object is instantiated (
new StockItem()) but has no identity in the database and is not tracked by Hibernate. - Managed: The entity is tracked. Any change to its fields will be detected by Dirty Checking and synchronized with the DB upon flushing.
- Detached: The entity has a database ID, but the Persistence Context is closed or the entity was explicitly detached. Changes here are ignored by Hibernate until the entity is merged back.
- Removed: The entity is scheduled for deletion.
The Mechanics of repository.save()
Spring Data save delegates to persist for an entity considered new and merge otherwise. By default, newness is determined from a non-primitive version property when available, then from whether the identifier is null. Implementing Persistable can override that decision. An assigned identifier therefore needs an explicit newness strategy.
persist makes the new instance managed; SQL timing depends on identifier generation and flushing. merge copies state into a managed instance and returns it. A detached argument remains detached: use the returned instance. Neither operation means the transaction has committed.
ID Generation Strategies and SQL Timing
With Hibernate, PostgreSQL and an active transaction using normal AUTO flushing, IDENTITY commonly requires an early INSERT to obtain the generated identifier. Do not generalize that timing to every provider, flush mode or transaction configuration.
SEQUENCE separates identifier allocation from row insertion. Hibernate may obtain a sequence value or use an already allocated range, depending on the optimizer and allocation size. The entity can have an ID before its INSERT is flushed. Having an ID proves neither that the row exists nor that a transaction committed.
Worked Example: Stock Item Lifecycle
Each trace below runs entirely inside an outer service transaction, with Hibernate, PostgreSQL and normal AUTO flushing. The managed entity stays attached until that transaction ends. Without this outer boundary, a repository call can finish its own transaction before returning, so a later setter is not automatically persisted. For the merge trace, the detached entity comes from a previous context; client request data should normally be validated and mapped onto a loaded entity rather than merged indiscriminately.
Consider a StockItem entity:
@Entity
public class StockItem {
@Id
@GeneratedValue(strategy = GenerationType.SEQUENCE)
private Long id;
private String sku;
private Integer quantity;
// Getters, Constructor, etc.
}
Trace 1: The Initial Insert
StockItem item = new StockItem("BOLT-01", 100);→ State: New.repository.save(item);→ Hibernate sees it is new → callspersist().- Because we use
SEQUENCE, Hibernate fetches the next ID (e.g.,1) and assigns it toitem. State: Managed. No SQLINSERThas happened yet. - Flush: When the transaction ends or
flush()is called, Hibernate generates:INSERT INTO stock_item (id, sku, quantity) VALUES (1, 'BOLT-01', 100);.
Trace 2: Managed Update (Dirty Checking)
StockItem item = repository.findById(1L).orElseThrow();→ State: Managed.item.setQuantity(80);→ No repository method is called. Hibernate’s dirty checking mechanism compares the current state with the snapshot taken during load.- Commit: Upon transaction commit, Hibernate detects the change and generates:
UPDATE stock_item SET quantity = 80 WHERE id = 1;.
Trace 3: The Detached Merge
- An entity is sent to a UI, modified, and sent back. It has an ID but is not in the current session → State: Detached.
StockItem detachedItem = ...; // quantity is 50StockItem managedItem = repository.save(detachedItem);→ Hibernate callsmerge().- Hibernate loads the current record from DB, copies
50into that managed instance, and returns it. - Flush:
UPDATE stock_item SET quantity = 50 WHERE id = 1;.
Flush vs. Commit
Flush synchronizes pending changes with the database by executing SQL inside the current transaction; it does not commit that transaction. In the PostgreSQL setup used here, the transaction sees its own changes while other ordinary transactions do not see its uncommitted writes. Visibility in general depends on isolation and database behavior.
Under normal AUTO flushing, committing the transaction flushes pending managed changes. MANUAL flushing and some read-only configurations require different handling. A successful flush can still be followed by rollback or a later commit failure.
Exercise
Inside an active Hibernate transaction with normal AUTO flushing and PostgreSQL IDENTITY, save a new StockItem and then remove it before commit. Predict an early INSERT to obtain the ID followed by DELETE when removals are flushed. Confirm both statements in the SQL log and then confirm no row remains after commit. Repeat with SEQUENCE and note that obtaining an identifier does not itself require inserting the row. Treat the observed timing as specific to this configuration, not a universal JPA promise.