How does save() decide between INSERT and UPDATE in Spring Data R2DBC, and what breaks with assigned IDs?
answer
- No persistence context → isNew from @Id
- id null/0 = INSERT else UPDATE
- Assigned id → UPDATE hits 0 rows silently
- Fix: Persistable, @Version, or template.insert()
- No dirty checking, no cascade
basics
~20 ssave() looks at the entity's @Id: if it is null (or 0 for primitives) the entity is treated as new and INSERTed; otherwise it is UPDATEd. With a manually assigned id, save() wrongly does an UPDATE that affects zero rows.
solid answer
~40 sUnlike JPA, R2DBC has no persistence context, so save() must decide INSERT vs UPDATE from the entity's isNew state. By default that check is: is the @Id property null (for wrapper types) or 0 (for primitives)? If new, it INSERTs and populates the generated id; otherwise it issues an UPDATE. This works perfectly with database-generated identities. But if you assign ids yourself (e.g. UUIDs, natural keys), a fresh entity already has a non-null id, so save() emits an UPDATE that matches no row and silently updates zero rows — the insert never happens. Fixes: implement Persistable<ID> and override isNew()/getId(), add a @Version field (version null/0 means new and also gives optimistic locking), or use R2dbcEntityTemplate.insert() explicitly to force an INSERT.
code
java · 23 lines// Assigned-id entity that would otherwise break save():
public class User implements Persistable<UUID> {
@Id private UUID id;
private String email;
@Transient private boolean isNew;
public static User create(String email) {
User u = new User();
u.id = UUID.randomUUID();
u.email = email;
u.isNew = true; // force INSERT on first save
return u;
}
@Override public UUID getId() { return id; }
@Override public boolean isNew() { return isNew; }
// Spring resets isNew=false after load; or flip it in an AfterSaveCallback
}
// Alternative: skip save() ambiguity entirely
// r2dbcEntityTemplate.insert(user); // always INSERT
// r2dbcEntityTemplate.update(user); // always UPDATEgo deeper
Know save() returns Mono and persists the entity.
Knows INSERT vs UPDATE is decided by whether the id is set.
Can diagnose the assigned-id silent-update bug and apply Persistable/@Version/template.insert().
Sets id-strategy conventions (DB-generated vs assigned) and standardizes @Version/optimistic-locking policy across services.
**Why this is a thing.** JPA tracks whether an entity is managed via its persistence context, so `merge`/`persist` know the state. R2DBC has **no persistence context and no change tracking**, so `ReactiveCrudRepository.save(entity)` must infer, from the object alone, whether to INSERT or UPDATE. It delegates to Spring Data's `isNew` detection. **Default isNew strategy.** For a standard entity, Spring Data uses the identifier: the entity is **new** when the `@Id` property is `null` (object types like `Long`, `UUID`, `String`) or `0` (primitive `long`/`int`). New → `INSERT` (and the generated key is read back and set on the entity). Not new → `UPDATE` of all columns by id. **The assigned-id trap.** Suppose you generate the key in code: `user.setId(UUID.randomUUID())` before `save()`. Now `getId()` is non-null, so `isNew` returns false, and `save()` issues an `UPDATE ... WHERE id = ?`. Since the row doesn't exist yet, **zero rows are updated and no exception is thrown** by default — the write is silently lost. This is one of the most common R2DBC surprises. The same happens with natural/business keys. **Fixes, in order of preference.** 1. **Prefer database-generated ids** (`GENERATED ... AS IDENTITY`, serial/sequence). The default detection then just works. 2. **Implement `Persistable<ID>`**: override `getId()` and `isNew()`. You typically back `isNew()` with a transient boolean flag set to true on construction and flipped false after load/save (Spring calls a callback / you use `@Transient`). This gives explicit control for assigned ids. 3. **Add a `@Version` field** (e.g. `@Version Long version`). Spring switches `isNew` detection to the version: null/0 version → new → INSERT; it also enables **optimistic locking** (concurrent update mismatches throw `OptimisticLockingFailureException`). 4. **Bypass save()**: call `R2dbcEntityTemplate.insert(entity)` to force an INSERT (or `update(...)` to force an UPDATE), removing the ambiguity entirely. **Related gotchas.** - `saveAll` has the same per-entity detection. - Because there's no dirty checking, you must call `save` explicitly after mutating an entity; simply changing a field does nothing. - There is no cascade — saving a parent does not save children (there are no relationships). - Callbacks like `BeforeConvertCallback`/`AfterSaveCallback` (reactive variants) are the R2DBC hook points if you need to stamp fields or manage `isNew`.
- Why does adding a @Version field fix the assigned-id insert problem, and what else does it give you?With @Version, isNew detection switches to the version property: a null/0 version means new, so a freshly built entity INSERTs even though its id is assigned. It additionally enables optimistic locking, throwing OptimisticLockingFailureException on concurrent update conflicts.
- After changing a field on a loaded entity, do you need to call save()? Why?Yes. R2DBC has no persistence context or dirty checking, so field mutations are not tracked or auto-flushed. Nothing is written unless you explicitly call save() (or template.update()).
saying these in an interview costs you the question
- Assuming R2DBC dirty-checks like JPA and auto-flushes changes
- Assigning ids in code and expecting save() to INSERT
- Expecting an exception when an UPDATE matches zero rows
- Thinking saving a parent cascades to children