·3 min read·Shipped

What Persistable<T> Taught Me About Hibernate's Entity State Detection

JavaHibernatePerformance

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.

text
[ 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:

java
@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.

Tulsi Kumari

Tulsi Kumari

·Backend SDE @ Texas Instruments

I write about transaction boundaries, database performance, and distributed systems self-study. If you're discussing backend architectures, incident debugging, or high-performance engineering roles, feel free to reach out.