What Persistable<T> Taught Me About Hibernate's Entity State Detection
I noticed a batch insert path was slower than it had any right to be. Not "slow because the query is bad" slow — slow in a way that scaled linearly with row count in a way batching is specifically supposed to prevent. The cause turned out to be something Hibernate does silently, for a reasonable reason, that stops being reasonable once you're using composite keys.
The setup
The entity in question used @EmbeddedId — a composite primary key made of two fields, both known at construction time rather than generated by the database. Nothing unusual on its own.
What Hibernate actually does
Hibernate needs to decide, for every entity you hand to save() or saveAll(), whether it's a new row (INSERT) or an existing one (UPDATE). For entities with a database-generated @Id (an auto-increment or a sequence), this is trivial: no ID yet means new, an ID means existing.
For an @EmbeddedId, the ID is always populated — you set it yourself before the entity ever touches Hibernate. So Hibernate can't tell new from existing just by looking at the ID. Its default fallback is to ask the database: issue a SELECT to check whether a row with that key already exists, then decide between INSERT and UPDATE based on the answer.
[ Default Hibernate Behavior with @EmbeddedId: Linear N+1 Tax ]
saveAll([5,000 entities])
├── Entity 1 ──► SELECT ... WHERE id=1 ──► INSERT INTO ... (Round-trip 1)
├── Entity 2 ──► SELECT ... WHERE id=2 ──► INSERT INTO ... (Round-trip 2)
└── ... (5,000 individual SELECT queries before each write. Total time: ~7 min)
[ Optimized with Persistable<T>: True JDBC Batching ]
saveAll([5,000 entities])
├── isNew() returns true (0 SELECT queries issued)
└── Single Batched JDBC Call:
INSERT INTO records VALUES (...), (...), (...), ... (Total time: < 1 sec)
The fix: Persistable<T>
Implementing Persistable<T> lets you tell Hibernate directly whether an entity is new, without it needing to ask the database:
@Embeddable
public class RecordKey implements Serializable {
private Long sourceId;
private Long batchId;
// equals/hashCode omitted for brevity
}
@Entity
public class Record implements Persistable<RecordKey> {
@EmbeddedId
private RecordKey id;
@Transient
private boolean isNew = true;
@Override
public RecordKey getId() {
return id;
}
@Override
public boolean isNew() {
return isNew;
}
@PostLoad
@PostPersist
void markNotNew() {
this.isNew = false;
}
}
With isNew() answered directly, Hibernate skips the existence-check SELECT entirely for genuinely new entities and goes straight to INSERT. That's also what unlocks proper JDBC batching — Hibernate can only batch a sequence of statements it's confident are all inserts; a mix of "maybe insert, maybe update, let me check" defeats batching before it starts.
Why this is worth knowing even if you rarely hit it
Auto-generated IDs are common enough that most people never encounter this. But the moment you have a legitimate reason for a composite or externally-assigned key — multi-tenant systems, natural keys, imported data with existing identifiers — this exact tax shows up, silently, and looks like a database performance problem rather than an ORM decision problem. It's worth knowing the shape of it before you go looking for the wrong thing.
