Write Skew Across Disjoint Rows
Two transactions read the same set of rows, reach compatible decisions, and then update different rows. Neither transaction overwrites the other’s write. Both commits can still leave the database in a state that violates a rule spanning those rows.
That shape is write skew. It is easy to miss because many concurrency discussions center on two writers contending for one row. Write skew has no such collision. The conflict exists at the level of an invariant inferred from several records, while the physical writes remain disjoint.
The anomaly matters only under concurrency models that permit the relevant execution. Snapshot-based isolation commonly gives each transaction a stable view and prevents some direct write conflicts, but a stable snapshot alone does not serialize decisions derived from a shared predicate. Serializable execution, explicit locking, or a schema-level constraint can close the gap when their semantics cover the invariant.
The invariant lives above either row
Consider an on-call roster with two active operators. The application requires at least one operator to remain on call.
The initial state is:
operator A: on_call = true
operator B: on_call = trueTransaction T1 reads both rows, sees B on call, and decides that A may leave. Transaction T2 reads the same starting state, sees A on call, and decides that B may leave.
Their writes target different rows:
T1: UPDATE operators SET on_call = false WHERE id = 'A'
T2: UPDATE operators SET on_call = false WHERE id = 'B'If both transactions can commit from their original views, the final state contains no on-call operator. Each decision was valid relative to the snapshot that produced it. The pair of decisions is invalid relative to the application invariant.
No lost update occurred. T1 did not replace data written by T2, and T2 did not replace data written by T1. Detecting only same-row write conflicts therefore does not detect this execution.
A consistent snapshot is not a serial history
A transaction reading from a stable snapshot avoids seeing a mixture of versions created at different moments during that transaction. That property is useful, but it answers a different question from serializability.
For the roster example, both T1 and T2 can receive internally consistent snapshots containing A and B as active. The problem appears when each transaction uses that snapshot as permission for a write and the database allows both disjoint writes to commit.
There is no serial ordering of those two successful decisions that produces the same reasoning. If T1 ran completely before T2, then T2 would observe A as inactive and reject B’s departure. If T2 ran first, T1 would reject A’s departure. The concurrent result therefore cannot be explained as either serial order while preserving the transaction logic.
This distinction is more precise than treating isolation levels as a simple ladder from weak to strong. A named isolation level has product-specific semantics, and database documentation should be consulted for the exact anomalies it permits. The analytical point is independent of product naming: repeatable reads from one snapshot do not, by themselves, guarantee that predicate-based decisions compose into a serial history.
Row locks work only when they cover the decision set
Locking the row being changed does not necessarily protect a cross-row invariant.
If T1 locks only A and T2 locks only B, the transactions still own disjoint lock sets. Each can retain the snapshot-derived belief that the other operator remains active.
A locking design has to make conflicting decisions contend on some common protected resource. One option is to lock all rows that participate in the decision before evaluating the invariant. Another is to represent the invariant through a parent or coordination row and lock that row while changing membership beneath it.
The second model changes the physical conflict shape:
roster 42
|
+-- operator A
`-- operator BIf every transaction that changes roster membership locks roster 42, two departures for the same roster can no longer proceed as unrelated writes. The shared row acts as a serialization point for that invariant.
This approach is conditional on every relevant writer following the same protocol. A code path that mutates an operator without acquiring the coordination lock bypasses the protection. Lock scope also affects concurrency: a coarse shared lock intentionally makes more transactions wait, even when their eventual row writes differ.
Predicate protection is broader than record protection
Some invariants are not naturally expressed as a fixed set of known rows. A reservation rule might state that no active booking may overlap a requested time interval. The decision depends on the absence of qualifying rows as much as on rows that already exist.
Locking only records returned by a query cannot lock a record that does not yet exist. Two transactions can each observe no conflicting booking and then insert different rows that overlap.
This is a predicate problem. A concurrency mechanism capable of protecting it must account for changes that alter the truth of the predicate, including inserts into a previously empty range. Database engines provide different mechanisms here. Serializable implementations may use predicate-oriented conflict tracking, key-range locking, or other techniques. Their exact behavior, retry requirements, and locking granularity are engine-specific.
The application-level distinction remains stable: a record lock protects identified records; an invariant expressed over a set or range may require protection that also covers membership in that set.
Constraints can move the invariant into the write path
When a database can express an invariant directly, a constraint can be stronger than an application check followed by a write. Constraint enforcement occurs inside the database’s concurrency machinery rather than depending on an earlier observation remaining valid.
Simple uniqueness is the familiar case. Two transactions can both fail to observe a username and attempt to insert it, but a correctly declared unique constraint prevents both conflicting values from committing.
Cross-row invariants are harder. A conventional row-level CHECK constraint generally evaluates values in the row being written and is not a portable mechanism for arbitrary queries over other rows. Some database systems offer exclusion constraints, indexed representations, materialized coordination state, or other features that can encode particular multi-row rules.
Schema design can also transform an invariant. Instead of asking whether any operator remains active across an arbitrary set, a model might store a single designated primary operator in a roster row with a foreign key to membership. That does not automatically reproduce every business rule, but it illustrates the broader technique: changing representation can turn a predicate over many rows into a conflict over one constrained value.
A useful constraint is one whose database semantics match the actual rule. Forcing a complex temporal or aggregate invariant into an unsuitable constraint can create a false sense of protection.
Serializable execution detects a dependency cycle
Serializable isolation aims to make committed transactions equivalent to some serial execution. For the roster case, both departures cannot appear in one valid serial history when each transaction rejects leaving the roster empty.
A database can enforce this property through locking or through detection of dangerous dependency structures, depending on the engine. In implementations that abort transactions to preserve serializability, application code must treat serialization failure as a normal concurrency outcome and retry the entire transaction from a fresh state when the operation is safe to retry.
Retrying only the final UPDATE is not equivalent. The decision depended on reads performed earlier in the transaction. After an abort, those reads may no longer justify the same write. A full retry re-evaluates the invariant against a new transactional view.
Serializable isolation also does not make arbitrary external side effects transactional. If code sends a remote request before a transaction later aborts, rerunning the database transaction cannot retract that already accepted remote action. Transaction retry boundaries therefore need to exclude or separately coordinate effects that are not governed by the database commit.
Version columns solve a different conflict unless the version is shared
Optimistic concurrency control often attaches a version to each row and updates only when the expected version still matches:
UPDATE operators
SET on_call = false,
version = version + 1
WHERE id = 'A'
AND version = 12;That protects A from an unnoticed concurrent modification to A. It does not detect T2 changing B, because A’s version remains 12.
A version token can protect a multi-row invariant when the token represents the shared aggregate rather than one member. If roster 42 has a version that every membership change advances conditionally, concurrent changes to different operator rows also contend on the roster version.
This is conceptually similar to the coordination lock: both introduce a shared conflict point matching the scope of the invariant. One uses pessimistic exclusion; the other can use a conditional write and retry. The useful boundary is not the mechanism’s label but the state whose version or lock represents permission to make the decision.
Testing needs an adversarial schedule
A sequential test of the roster rule will pass. The first departure leaves one operator active, and a later departure is rejected. The anomaly requires overlapping transactions that both make their decision before either conflicting outcome becomes visible.
A concurrency test can coordinate two database sessions so both read the initial state, then allow their writes and commits to proceed. The expected result depends on the chosen protection. Under a serializable design, at least one transaction may be forced to retry. Under a shared-lock design, one transaction should wait and then evaluate state that includes the other committed change. Under an unprotected snapshot execution, both may commit if the database permits that anomaly.
Such a test is valuable because it targets the schedule that carries the risk. High parallelism alone does not guarantee that the relevant interleaving occurs, and a test that merely launches many requests can pass repeatedly without exercising the decision window.
The database used for the test also matters. An in-memory substitute with different isolation semantics cannot establish the behavior of the production database engine. For concurrency properties, the engine and configuration are part of the observable contract.
The conflict key should match the invariant
Write skew exposes a mismatch between logical contention and physical contention. Two transactions can disagree over one business rule while touching different records, so a storage engine that only needs to arbitrate those record writes has no direct conflict to resolve.
Protection becomes clearer when the system has a conflict key at the same scope as the invariant: a locked roster row, a shared aggregate version, a database constraint, a protected predicate, or serializable dependency tracking. Each mechanism gives the database a way to connect decisions that would otherwise appear independent.
The important design question is therefore not whether concurrent transactions write the same row. It is whether transactions that can jointly invalidate one rule are forced into a relationship the database can observe. When that relationship is absent, disjoint writes can still produce one invalid state.