@Version
Adds optimistic locking: Hibernate checks and increments a version column on every update, so concurrent edits fail instead of overwriting each other.
@Version
private Long version;- Added in Spring Boot JPA 1.0 (javax.persistence)
Parameters
| Parameter | Type | Description |
|---|---|---|
$(field type) | int, Integer, short, Short, long, Long, java.sql.Timestamp | The version value. Use a wrapper such as Long so that null means a new entity. Hibernate also accepts some temporal types, but counters avoid clock problems. |
Return value
n/aMarks the field Hibernate uses for optimistic locking: it is added to the WHERE clause of each UPDATE and DELETE and incremented on every update.
Errors
When the row was changed by someone else, Hibernate's optimistic lock exception is translated by Spring into ObjectOptimisticLockingFailureException, a subclass of OptimisticLockingFailureException.
Two users open the same product, both change the price, both save. Without any locking, the second save silently overwrites the first: the lost update. @Version prevents it without holding database locks. Add one field, and Hibernate turns every update into:
update product set price = ?, version = 8
where id = ? and version = 7If another transaction already moved the row to version 8, the statement updates zero rows, Hibernate throws, and Spring translates the error to ObjectOptimisticLockingFailureException. Catch its parent, OptimisticLockingFailureException from org.springframework.dao, so your handler does not depend on JPA.
Rules for the field
- Use
LongorInteger. A wrapper that isnullbefore the first save also tells Spring Data the entity is new, which avoids an extraSELECTfor entities with assigned ids. - Never set it yourself. Hibernate initialises it on insert and increments it on update. Provide a getter, no setter.
- Add a
NOT NULLcolumn with a default in the migration when introducing it to an existing table, or existing rows withNULLare treated as new.
Protecting edits across requests
Inside one transaction the check is automatic. The real conflict usually spans two HTTP requests: the client reads version 7, the user edits for a minute, then the client sends the update. If the service simply loads the current entity and applies the change, it reads version 8 and overwrites the other edit anyway. Send the version to the client (in the DTO or as an ETag), have it sent back (in the body or If-Match), and compare it with the loaded entity before applying changes.
Turning conflicts into responses
Map the exception to 409 Conflict (or 412 Precondition Failed when you use If-Match) so the client can reload and let the user decide. Unhandled, it becomes a 500 and the user loses their input with no explanation.
For conflicts that are safe to repeat automatically, such as incrementing a counter, retry the whole transaction. The retry must wrap the transactional call from outside, because a transaction that saw the failure is already rolled back.
Bulk @Modifying updates bypass the check and do not increment the version. Include version = version + 1 in the query, or concurrent edits to those rows go undetected.
Example
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
import java.math.BigDecimal;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
class Product {
private Long id;
private BigDecimal price;
private Long version;
protected Product() { }
Long getVersion() { return version; }
void reprice(BigDecimal price) { this.price = price; }
}
interface ProductRepository extends JpaRepository<Product, Long> { }
class PricingService {
private final ProductRepository products;
PricingService(ProductRepository products) { this.products = products; }
public void reprice(Long id, long expectedVersion, BigDecimal price) {
Product product = products.findById(id).orElseThrow();
if (product.getVersion() != expectedVersion) {
// someone saved between the client's read and this write
throw new ObjectOptimisticLockingFailureException(Product.class, id);
}
product.reprice(price); // UPDATE ... where id = ? and version = ?
}
}
public class ConcurrencyErrors {
(OptimisticLockingFailureException.class)
ProblemDetail conflict(OptimisticLockingFailureException e) {
ProblemDetail pd = ProblemDetail.forStatusAndDetail(HttpStatus.CONFLICT,
"The resource was changed by someone else. Reload it and try again.");
pd.setTitle("Edit conflict");
return pd;
}
}When to use it
- Stopping two back-office users from overwriting each other's product price changes.
- Implementing ETag and If-Match on a REST resource so clients cannot update a stale copy.
- Retrying a like counter increment automatically when two requests collide.
Frequently asked questions
What exception does a version conflict throw in Spring Boot?
ObjectOptimisticLockingFailureException from org.springframework.orm, which extends OptimisticLockingFailureException in org.springframework.dao. Handle the parent type and map it to 409 or 412.
Optimistic or pessimistic locking?
Optimistic locking suits most web applications, where conflicts are rare and holding row locks across a user's think time is impossible. Use pessimistic locks (@Lock on a repository method) for short, highly contended updates such as a stock counter under heavy load.
Should the version appear in my API?
Yes when clients edit resources. Without it the server cannot tell that the client's copy is stale, and the check only protects against conflicts inside a single transaction.
More examples
ETag and If-Match on a REST endpoint
The ETag carries the version to the client and If-Match brings it back. The transaction lives in NoteService, so the controller only deals with HTTP: it turns a stale copy into 412 before any change is applied. A write that races in between the check and the commit is still caught by the version column: the call to service.update then throws ObjectOptimisticLockingFailureException when the transaction commits, so map OptimisticLockingFailureException to 412 with an @ExceptionHandler like the one in the main example.
import jakarta.persistence.Entity;
import jakarta.persistence.Id;
import jakarta.persistence.Version;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RestController;
@Entity
class Note {
@Id
private Long id;
private String text;
@Version
private Long version;
protected Note() { }
String getText() { return text; }
Long getVersion() { return version; }
void setText(String text) { this.text = text; }
}
interface NoteRepository extends JpaRepository<Note, Long> { }
record NoteBody(String text) { }
record NoteView(String text, String etag) { }
@Service
class NoteService {
private final NoteRepository notes;
NoteService(NoteRepository notes) { this.notes = notes; }
static String etag(Note note) { return "\"" + note.getVersion() + "\""; }
@Transactional(readOnly = true)
public NoteView find(Long id) {
Note note = notes.findById(id).orElseThrow();
return new NoteView(note.getText(), etag(note));
}
/** Returns false when the client's copy is stale. */
@Transactional
public boolean update(Long id, String ifMatch, String text) {
Note note = notes.findById(id).orElseThrow();
if (!ifMatch.equals(etag(note))) {
return false;
}
note.setText(text);
return true;
}
}
@RestController
public class NoteController {
private final NoteService service;
public NoteController(NoteService service) { this.service = service; }
@GetMapping("/notes/{id}")
public ResponseEntity<NoteBody> get(@PathVariable Long id) {
NoteView view = service.find(id);
return ResponseEntity.ok().eTag(view.etag()).body(new NoteBody(view.text()));
}
@PutMapping("/notes/{id}")
public ResponseEntity<Void> update(@PathVariable Long id,
@RequestHeader("If-Match") String ifMatch,
@RequestBody NoteBody body) {
if (!service.update(id, ifMatch, body.text())) {
return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
}
return ResponseEntity.noContent().build();
}
}Retrying a safe conflict
Each attempt calls the transactional method afresh, so it reloads the latest version. Putting @Retryable on the transactional method itself is fragile, because the retry has to sit outside the transaction. Only retry operations that make sense to repeat without asking the user.
import org.springframework.context.annotation.Configuration;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.resilience.annotation.EnableResilientMethods;
import org.springframework.resilience.annotation.Retryable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@Configuration
@EnableResilientMethods
class ResilienceConfig { }
@Service
class CounterWriter {
@Transactional
public void increment(Long counterId) {
// load the counter entity, add one; @Version detects concurrent increments
}
}
@Service
public class LikeService {
private final CounterWriter writer;
public LikeService(CounterWriter writer) { this.writer = writer; }
// retries wrap the whole transaction, which lives in another bean
@Retryable(includes = OptimisticLockingFailureException.class, maxRetries = 3, delay = 50, jitter = 25)
public void like(Long postId) {
writer.increment(postId);
}
}Adding a version column to an existing table
The default gives every existing row a version of 0, so Hibernate treats them as existing entities and starts incrementing from there. Leaving the column nullable makes old rows behave as if they were new.
-- V7__product_version.sql
alter table product add column version bigint not null default 0;
Discussion