skip to content

How does Spring Data JDBC decide between INSERT and UPDATE, and how do you make it work with assigned (non-generated) @Ids?

level: principalimportance: should knowfreq 40%

answer

  1. id null/0 => new => INSERT
  2. generated key read back after insert
  3. assigned id trap => phantom UPDATE, 0 rows
  4. @Version: null=new + optimistic lock
  5. Persistable.isNew() or BeforeConvertCallback

basics

~20 s

By default save() treats an entity as new (INSERT) when its @Id is null (or 0 for a primitive), otherwise UPDATE. With ids you assign yourself, a non-null id looks 'existing', so you implement Persistable.isNew(), add a @Version field, or set the id in a callback.

solid answer

~40 s

save() must decide new-vs-existing. The default rule: the @Id property being null (object) or zero (primitive number) means new -> INSERT; a set id means existing -> UPDATE. With database-generated keys this is automatic — after INSERT the id is read back into the object. The trap is assigned ids: you set a non-null id before the first save, so Spring thinks it already exists and issues an UPDATE that updates zero rows. Fixes: (1) implement Persistable<ID> and return isNew() from your own flag; (2) add a @Version field — a null/zero version means new and Spring manages it (also giving optimistic locking); or (3) assign the id in a BeforeConvertCallback while a separate isNew flag drives the decision. @Version is the common, clean solution.

code

java · 19 lines
java
// Assigned UUID id — default strategy would wrongly see it as existing.
// @Version fixes new-vs-existing AND adds optimistic locking:
class Account {
    @Id UUID id;            // assigned by the application, non-null before first save
    @Version Long version;  // null => NEW (INSERT); non-null => EXISTING (UPDATE)
    String owner;

    Account(UUID id, String owner) { this.id = id; this.owner = owner; }
}

// Alternative: explicit control via Persistable
class Invoice implements Persistable<String> {
    @Id private final String number;   // natural key, assigned
    @Transient private boolean isNew = true;
    Invoice(String number) { this.number = number; }
    @Override public String getId() { return number; }
    @Override public boolean isNew() { return isNew; }
    @PersistenceCreator Invoice(String number, boolean marker) { this.number = number; this.isNew = false; }
}

go deeper

for a junior

Know save() inserts when the id is null/0 and updates otherwise.

for a middle

Explain the generated-key round-trip and that a set id means UPDATE.

for a senior

Diagnose the assigned-id trap and fix it with @Version or Persistable.isNew().

for a principal

Compare IsNewStrategy options, tie @Version to optimistic locking, and advise on id strategy (generated vs assigned) and callbacks for central id assignment.

**The decision `save()` must make.** `CrudRepository.save(entity)` is used for both inserts and updates, so Spring Data JDBC must decide whether the aggregate is **new** (needs `INSERT`) or **existing** (needs `UPDATE`). This decision is made by the entity's **`IsNewStrategy`**. **Default strategy — based on the `@Id`.** Out of the box Spring inspects the `@Id` property: - **Object/wrapper id** (e.g. `Long`, `String`, `UUID`): `null` → **new**; non-null → **existing**. - **Primitive numeric id** (e.g. `long`, `int`): `0` → **new**; non-zero → **existing**. **Why this is seamless with generated keys.** With a database-generated primary key you leave the id null/0; `save` sees 'new', runs `INSERT`, and Spring **reads the generated key back into the entity's `@Id`** after insert. The next `save` sees a non-null id and does an `UPDATE`. No extra code needed. **The assigned-id trap.** If you generate the id yourself (natural key, UUID, application-assigned), you set the `@Id` to a non-null value **before** the first save. The default strategy then sees a non-null id and issues an **`UPDATE`** — which matches **zero rows** because the row doesn't exist yet, so the insert never happens and the data is silently lost. This is the classic gotcha. **Three ways to fix assigned ids.** 1. **Implement `Persistable<ID>`.** The entity implements `getId()` and `isNew()`. You control `isNew()` explicitly (e.g. a transient boolean set to `true` on construction and cleared after load/save). Spring calls your `isNew()` instead of the id check. Most explicit, most boilerplate. 2. **Add an `@Version` field** (e.g. `@Version Long version` or `int`). A `null`/`0` version marks the aggregate **new**; Spring inserts, then sets version to the initial value; subsequent saves see a non-null version → `UPDATE`, and each update increments it. This simultaneously gives **optimistic locking** (a stale version throws `OptimisticLockingFailureException`). It's the cleanest, most idiomatic choice and needs no `Persistable` boilerplate. 3. **Assign the id in a `BeforeConvertCallback`** (or the older `BeforeSaveCallback`) — populate the id just before persistence while some other signal (usually `Persistable.isNew()` or `@Version`) drives the new/existing decision. Useful when the DB isn't generating the key but you still want central id assignment logic. **Related mechanics.** - **Where the id comes from for generated keys:** the JDBC driver returns generated keys on INSERT; Spring writes them back via the entity's persistence constructor/property. - **`@Version` and locking:** even with generated ids, adding `@Version` is a good idea when concurrent updates are possible; the whole-aggregate save fails atomically on a version mismatch. - **Children:** the new/existing decision is about the **root**; children are rewritten regardless (delete-and-recreate), so their ids don't drive the root's INSERT/UPDATE choice. **Gotchas.** (1) Assigned ids without `Persistable`/`@Version` → silent no-op update. (2) A primitive-`long` id can never represent 'new' via null — 0 is the only 'new' marker, so a legitimate id of 0 is impossible; prefer wrapper types or `@Version`. (3) Forgetting to reset a `Persistable.isNew()` flag after load makes an existing entity look new → duplicate insert / PK violation. **When to use which.** Generated keys → rely on the default. Assigned keys or concurrency concerns → add `@Version` (preferred). Complex custom id/newness logic → `Persistable` and/or a `BeforeConvertCallback`.

  • Why does saving an entity with an application-assigned id sometimes silently do nothing?
    The default strategy sees the non-null id as 'existing' and issues an UPDATE, which matches zero rows because the record doesn't exist yet. No INSERT runs, so the data is lost. Fix with @Version or Persistable.isNew().
  • How does adding a @Version field solve the assigned-id problem, and what else does it give you?
    A null/zero version marks the aggregate new (INSERT), then Spring sets and increments it so later saves UPDATE. It also provides optimistic locking — a stale version throws OptimisticLockingFailureException.
  • Why is a primitive long a poor choice for an assigned id?
    A primitive can't be null, so only 0 signals 'new'. That makes a real id of 0 impossible and offers no clean newness marker; use a wrapper type or drive the decision with @Version/Persistable.

saying these in an interview costs you the question

  • Assuming save() always inserts, or always upserts
  • Setting an assigned id without Persistable/@Version and expecting an INSERT
  • Thinking generated ids must be set manually
  • Forgetting to clear Persistable.isNew() after load, causing duplicate inserts

context