skip to content

You assign your own @Id (e.g. a UUID or natural key). Why do inserts now behave oddly, and what goes wrong under the hood?

level: seniorimportance: must knowfreq 50%

answer

  1. assigned id -> id never null -> isNew false
  2. JPA: merge = wasted SELECT + detached copy
  3. JDBC/Mongo: UPDATE hits 0 rows, no error, data lost
  4. hidden by same-tx read-back
  5. fix: Persistable / nullable @Version / transient flag

basics

~20 s

With a manually assigned id, the id is never null, so the default is-new check reports 'existing'. In JPA that routes new objects through merge instead of persist: an extra SELECT, and if the row is truly missing, a surprising insert-after-select. In JDBC/Mongo it issues an UPDATE that silently affects zero rows.

solid answer

~40 s

The default is-new strategy equates 'new' with 'null id'. When you assign ids yourself, the id is populated before the first save, so `isNew` returns `false` and save() takes the update path. In **JPA** that's `EntityManager.merge`: Hibernate first issues a `SELECT` to load the current row, finds nothing, and then inserts — so every create pays for a wasted query, and detached-entity semantics can bite. In **Spring Data JDBC/R2DBC/MongoDB** there's no merge fallback: save() emits an `UPDATE` that matches zero rows, so the record silently isn't persisted (no exception). Fixes: implement `Persistable<ID>.isNew()` (often backed by a transient flag or a null `@CreatedDate`), add a nullable `@Version` field, or in Spring Data JDBC use the persistence-context-free strategy plus Persistable. The root cause is always the id-null assumption breaking for assigned ids.

code

java · 20 lines
java
// The trap
@Entity
class Invoice {
    @Id
    private UUID id;              // assigned by caller/service -> never null
    private BigDecimal total;
}

invoice.setId(UUID.randomUUID());
repo.save(invoice);
// JPA:  isNew()==false -> em.merge -> SELECT (miss) -> INSERT   (extra query)
// JDBC: isNew()==false -> UPDATE WHERE id=? -> 0 rows -> NOTHING SAVED, no error

// One fix: nullable @Version restores a reliable 'new' signal
@Entity
class InvoiceFixed {
    @Id private UUID id;
    @Version private Long version;   // null on fresh object -> INSERT via persist/insert
    private BigDecimal total;
}

go deeper

for a junior

Recognize that assigning your own id can make save() misbehave.

for a middle

Explain that a non-null assigned id makes isNew() false, routing new objects to the update path.

for a senior

Contrast the JPA merge (wasted SELECT) vs JDBC zero-row UPDATE (silent data loss) failure modes and name the fixes.

for a principal

Design an id/versioning strategy that keeps is-new detection correct across stores and prevents the silent-loss class of bug.

## The assumption that breaks Everything in the default strategy rests on: *the id is null until the first save*. That holds for `@GeneratedValue`/identity/sequence ids. It is **false** for manually assigned ids — client-supplied UUIDs, natural/business keys, or ids you set in a constructor (`this.id = UUID.randomUUID()`). Now the id is non-null the instant the object exists, so `isNew()` returns `false` for a genuinely new record. ## What actually happens per store ### Spring Data JPA (persistence context present) `SimpleJpaRepository.save` sees `isNew == false` and calls `entityManager.merge(entity)`. `merge` is designed for detached entities that *probably already exist*, so Hibernate: 1. Issues a `SELECT` to load the current managed state by id. 2. Finds no row. 3. Schedules an `INSERT` on flush. The insert still happens (JPA is forgiving), but you paid for an **extra SELECT on every create**. At scale that doubles your read load. `merge` also returns a *new managed copy* — if you keep using the object you passed in, it's still detached, a classic source of 'my changes aren't saved' confusion. ### Spring Data JDBC / R2DBC (no persistence context) There is no merge. `JdbcAggregateTemplate.save` asks `isNew`; getting `false`, it emits an `UPDATE ... WHERE id = ?`. The row doesn't exist, so the statement updates **zero rows** — and **no exception is thrown**. Your 'saved' entity silently vanishes. This is the nastier failure mode because it's a data-loss bug, not just a perf hit. ### MongoDB Same shape: an existing-entity save becomes a replace/update keyed by `_id`; with no matching document it updates nothing (unless upsert semantics apply for that path). ## Why it's easy to miss - Tests that create *and read back in the same transaction* can hide the JPA case (the entity is in the first-level cache). - The JDBC zero-row update throws nothing, so it only surfaces as 'the record isn't there' later. ## The fixes (in order of preference) 1. **Implement `Persistable<ID>`** and provide an accurate `isNew()`. Common backings: a `@Transient boolean isNew = true` flag flipped to false via `@PostLoad`/`@PostPersist`, or `return createdDate == null` using an `@CreatedDate` field. 2. **Add a nullable `@Version` field** (`Long`/`Integer`) — null version = new. Zero boilerplate, and you get optimistic locking too. (Must be a boxed type.) 3. **Spring Data JDBC**: same Persistable/`@Version` approaches; there's no persistence context to lean on, so explicit state detection matters even more. 4. Avoid: forcing behavior by calling a lower-level `insert`/`persist` directly everywhere — it works but leaks persistence concerns into calling code. ## Interview-grade summary The pitfall is a mismatch between *how you supply ids* and *how the framework infers newness*. Assigned ids remove the null signal, so you must supply a replacement signal (version, transient flag, created-date, or Persistable). ## Key terms - **Merge** — JPA operation to reattach detached state; issues a load then update/insert. - **JdbcAggregateTemplate** — Spring Data JDBC's core save engine; no persistence context, so is-new directly picks INSERT vs UPDATE SQL.

  • Why is the Spring Data JDBC failure mode more dangerous than the JPA one for assigned ids?
    JPA's merge still inserts the missing row (just with a wasted SELECT), so data is saved. Spring Data JDBC has no merge: it issues an UPDATE that matches zero rows and throws no exception, so the record is silently not persisted — a data-loss bug that surfaces later.
  • How can a passing integration test hide the JPA merge problem?
    If the test creates the entity and reads it back within the same transaction/persistence context, the entity sits in the first-level cache and the read succeeds regardless of the extra SELECT, so the wasted query and detached-copy semantics go unnoticed until production load or a cross-transaction read.

saying these in an interview costs you the question

  • Assuming assigned ids just work like generated ids because 'save is save'.
  • Saying the JDBC case throws an exception when the row is missing — it silently updates zero rows.
  • Recommending calling merge/persist manually everywhere instead of fixing is-new detection.
  • Believing an extra SELECT on merge is negligible at any scale.

context