The silent data corruption bug that shipped to production
Last month, your support team flagged that customer orders were losing line items without warning. The audit log showed no deletes. Two staff members had opened the same order, each added items, and the second save simply overwrote the first. This is the classic lost-update problem, and it lives in almost every web application that doesn't explicitly handle concurrent edits. Optimistic locking with @Version: lessons from production is how we prevent it—without the performance penalty of pessimistic locks that serialize writes and destroy throughput.
Understanding the problem: two users, one record, silent data loss
When two threads or HTTP requests modify the same database row at nearly the same time, the last write wins by default. Here's what actually happens:
- User A loads Order #123, version = 1. Status = "draft". Line items = [].
- User B loads Order #123, version = 1. Same state.
- User A adds item X, saves. Database updates Order #123, version becomes 2.
- User B adds item Y, saves. Database updates Order #123, version stays 2 (or increments again). Item X is gone.
In a traditional UPDATE without version checking, the second update applies without knowing that User A's changes existed. The application never sees a conflict. Customer data silently corrupts. No exceptions. No warnings in logs. This is worse than a crash because you discover it weeks later in production when someone manually reconciles records.
Pessimistic locking (row-level database locks) prevents this by serializing writes—only one transaction modifies the row at a time. But locks block other threads, reduce concurrency, and scale poorly under load. Optimistic locking trades certainty for speed: we assume collisions are rare, let multiple readers proceed freely, and detect the collision at write time by checking if the underlying data changed since we read it.
Adding a version column with @Version
Hibernate's @Version annotation implements optimistic locking elegantly. You add an integer or long field to your entity, mark it with @Version, and Hibernate automatically increments it on every update and validates it on writes.
import jakarta.persistence.*;
@Entity
@Table(name = "orders")
public class Order {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false)
private String customerId;
@Column(nullable = false)
private String status; // "draft", "confirmed", "shipped"
@Version
@Column(nullable = false)
private Long version;
// Constructor, getters, setters
public Order() {}
public Order(String customerId, String status) {
this.customerId = customerId;
this.status = status;
this.version = 0L;
}
public Long getId() { return id; }
public String getCustomerId() { return customerId; }
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Long getVersion() { return version; }
public void setVersion(Long version) { this.version = version; }
}The version column must exist in the database schema. Create a migration:
-- Migration V1.0__add_order_version.sql
ALTER TABLE orders ADD COLUMN version BIGINT NOT NULL DEFAULT 0;
CREATE INDEX idx_orders_version ON orders(id, version);Now, when Hibernate generates an UPDATE statement, it includes the version in the WHERE clause:
UPDATE orders
SET status = ?, version = version + 1
WHERE id = ? AND version = ?If the version in the database no longer matches the version we loaded, the UPDATE affects zero rows. Hibernate detects this and throws OptimisticLockException.
Catching OptimisticLockException and returning 409 Conflict
In your service or controller, wrap the save in a try-catch. When optimistic locking fails, return HTTP 409 Conflict to tell the client "your changes collided with someone else's; reload and retry."
import org.springframework.web.bind.annotation.*;
import org.hibernate.OptimisticLockException;
import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.ResponseEntity;
import org.springframework.http.HttpStatus;
@RestController
@RequestMapping("/api/orders")
public class OrderController {
private final OrderRepository orderRepository;
private final OrderService orderService;
public OrderController(OrderRepository orderRepository, OrderService orderService) {
this.orderRepository = orderRepository;
this.orderService = orderService;
}
@PutMapping("/{id}")
public ResponseEntity updateOrder(
@PathVariable Long id,
@RequestBody UpdateOrderRequest request) {
try {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Order not found"));
// Simulate business logic
order.setStatus(request.getStatus());
Order saved = orderRepository.save(order);
return ResponseEntity.ok(new OrderResponse(
saved.getId(),
saved.getStatus(),
saved.getVersion()
));
} catch (OptimisticLockingFailureException e) {
// Version mismatch: another thread updated this order
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ErrorResponse(
"Order was modified by another user. " +
"Please reload and try again.",
"CONFLICT"
));
}
}
}
class UpdateOrderRequest {
private String status;
private Long version; // Client sends the version it read
public UpdateOrderRequest() {}
public UpdateOrderRequest(String status, Long version) {
this.status = status;
this.version = version;
}
public String getStatus() { return status; }
public void setStatus(String status) { this.status = status; }
public Long getVersion() { return version; }
public void setVersion(Long version) { this.version = version; }
}
class OrderResponse {
private Long id;
private String status;
private Long version;
public OrderResponse(Long id, String status, Long version) {
this.id = id;
this.status = status;
this.version = version;
}
public Long getId() { return id; }
public String getStatus() { return status; }
public Long getVersion() { return version; }
}
class ErrorResponse {
private String message;
private String code;
public ErrorResponse(String message, String code) {
this.message = message;
this.code = code;
}
public String getMessage() { return message; }
public String getCode() { return code; }
}The exception typically wraps OptimisticLockException in Spring Data's OptimisticLockingFailureException. Some teams also retry once or twice automatically, but be cautious: retrying too aggressively can mask real issues or cause thrashing under high contention.
Send the version to clients: ETags and response headers
For optimistic locking to work end-to-end, the client must prove which version of the record it's editing. When you return an order in a GET response, include the version—either as a response header (ETag) or in the JSON body.
@GetMapping("/{id}")
public ResponseEntity getOrder(@PathVariable Long id) {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Order not found"));
// Approach 1: Include version in response body
OrderResponse response = new OrderResponse(
order.getId(),
order.getStatus(),
order.getVersion()
);
// Approach 2: Set ETag header (optional, for caching)
return ResponseEntity.ok()
.eTag("\"" + order.getVersion() + "\"")
.body(response);
}The client then sends the version back in the update request. If the server's version differs, reject the update.
@PutMapping("/{id}")
public ResponseEntity updateOrder(
@PathVariable Long id,
@RequestBody UpdateOrderRequest request) {
try {
Order order = orderRepository.findById(id)
.orElseThrow(() -> new IllegalArgumentException("Order not found"));
// Verify client sent the right version
if (!order.getVersion().equals(request.getVersion())) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ErrorResponse(
"Order version mismatch. Reload and retry.",
"CONFLICT"
));
}
order.setStatus(request.getStatus());
Order saved = orderRepository.save(order);
return ResponseEntity.ok()
.eTag("\"" + saved.getVersion() + "\"")
.body(new OrderResponse(
saved.getId(),
saved.getStatus(),
saved.getVersion()
));
} catch (OptimisticLockingFailureException e) {
return ResponseEntity
.status(HttpStatus.CONFLICT)
.body(new ErrorResponse(
"Order was modified. Reload and retry.",
"CONFLICT"
));
}
}On the frontend (React, Vue, etc.), store the version alongside the record. When the user submits an edit, send both the new data and the version. If you get a 409, reload the record from the server and show "Your changes collided; please review and retry."
Common mistakes
- Forgetting to include version in the database schema. If the migration doesn't add the column or sets it nullable, Hibernate silently fails to use it. Always audit the schema after deployment.
- Not sending version to the client. If your API doesn't return the version, the client can't prove which state it edited. You're back to lost updates. Include it in every GET and PUT response.
- Treating OptimisticLockException as a fatal error. It's expected under contention. Log it, return 409, and let the client retry. Crashing or alerting the team wastes effort.
- Using optimistic locking on high-contention rows. If the same row is updated hundreds of times per second from different threads, optimistic locking wastes CPU on retries. Consider partitioning the data or using a distributed lock (Redis, Consul) for that specific row.
- Incrementing version manually. Let Hibernate do it. If you manually set
version++, you break the contract and reintroduce collisions. - Not mapping @Version in all environments. If your test database lacks the version column, tests pass but production fails. Use the same schema everywhere or generate it from Flyway/Liquibase in all profiles.
Key takeaways
- Optimistic locking trades the safety of row locks for the concurrency of free reads, detecting collisions at write time via a version column.
- Add a
@Versionfield to your entity, run a migration to add the column, and Hibernate automatically increments and validates it on updates. - Catch
OptimisticLockingFailureExceptionin your service or controller and return HTTP 409 Conflict; let the client reload and retry. - Always send the version to clients in your API responses (JSON body or ETag header) so they can prove which state they edited.
- Don't manually increment version, don't skip the database migration, and don't treat 409 as a system failure—it's a normal business outcome.
- For very high-contention rows, consider partitioning or distributed locks instead of optimistic retries.






Comments
Loading comments…